@parhelia/localization 0.1.12570 → 0.1.12585

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 (28) hide show
  1. package/dist/LocalizeItemDialog.d.ts.map +1 -1
  2. package/dist/LocalizeItemDialog.js +13 -17
  3. package/dist/api/discovery.d.ts +1 -4
  4. package/dist/api/discovery.d.ts.map +1 -1
  5. package/dist/api/discovery.js +3 -4
  6. package/dist/hooks/useTranslationWizard.d.ts.map +1 -1
  7. package/dist/hooks/useTranslationWizard.js +3 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +13 -28
  10. package/dist/services/translationService.d.ts +48 -7
  11. package/dist/services/translationService.d.ts.map +1 -1
  12. package/dist/services/translationService.js +3 -0
  13. package/dist/settings/TranslationServicesPanel.d.ts.map +1 -1
  14. package/dist/settings/TranslationServicesPanel.js +111 -42
  15. package/dist/setup/LocalizationSetupStep.d.ts.map +1 -1
  16. package/dist/setup/LocalizationSetupStep.js +8 -7
  17. package/dist/steps/ServiceLanguageSelectionStep.d.ts.map +1 -1
  18. package/dist/steps/ServiceLanguageSelectionStep.js +18 -1
  19. package/dist/steps/SubitemDiscoveryStep.d.ts.map +1 -1
  20. package/dist/steps/SubitemDiscoveryStep.js +95 -181
  21. package/dist/translation-center/BatchTranslationView.d.ts +1 -1
  22. package/dist/translation-center/BatchTranslationView.d.ts.map +1 -1
  23. package/dist/translation-center/BatchTranslationView.js +356 -98
  24. package/dist/translation-center/RecentTranslations.d.ts.map +1 -1
  25. package/dist/translation-center/RecentTranslations.js +20 -13
  26. package/dist/translation-center/TranslationManagement.d.ts.map +1 -1
  27. package/dist/translation-center/TranslationManagement.js +2 -4
  28. package/package.json +10 -11
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useEffect, useState, useMemo, useRef, useCallback, memo, } from "react";
3
- import { useEditContext, SimpleTable, Progress as CoreProgress, Button, } from "@parhelia/core";
4
- import { listBatchTranslationJobs, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders } from "../services/translationService";
5
- import { ChevronDown, ChevronUp } from "lucide-react";
3
+ import { useEditContext, SimpleTable, Progress as CoreProgress, Button, SimpleIconButton, CopyButton, usePathname, useRouter, useSearchParams, } from "@parhelia/core";
4
+ import { listBatchTranslationJobs, retryBatchTranslation, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders, } from "../services/translationService";
5
+ import { ChevronDown, ChevronUp, RefreshCw, ArrowLeft, Info, Loader2, Languages, ExternalLink, } from "lucide-react";
6
+ import { toast } from "sonner";
6
7
  import { JsonView, defaultStyles } from "react-json-view-lite";
7
8
  import "react-json-view-lite/dist/index.css";
8
9
  // Wrapper so React 19 sees a plain function component instead of a forwardRef exotic component.
@@ -10,6 +11,12 @@ const Progress = (props) => React.createElement(CoreProgress, props);
10
11
  // Wrappers for lucide-react icons to work with React 19
11
12
  const ChevronUpIcon = (props) => React.createElement(ChevronUp, props);
12
13
  const ChevronDownIcon = (props) => React.createElement(ChevronDown, props);
14
+ const RefreshIcon = (props) => React.createElement(RefreshCw, props);
15
+ const ArrowLeftIcon = (props) => React.createElement(ArrowLeft, props);
16
+ const InfoIcon = (props) => React.createElement(Info, props);
17
+ const LoaderIcon = (props) => React.createElement(Loader2, props);
18
+ const LanguagesIcon = (props) => React.createElement(Languages, props);
19
+ const ExternalLinkIcon = (props) => React.createElement(ExternalLink, props);
13
20
  const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, shouldExpandNode: (level) => level < 2, style: defaultStyles })), (prevProps, nextProps) => {
14
21
  return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
15
22
  });
@@ -17,32 +24,66 @@ const MemoizedJsonView = memo(({ data }) => (_jsx(JsonView, { data: data, should
17
24
  function normalizeBatchInfo(raw) {
18
25
  if (!raw)
19
26
  return null;
20
- const pick = (a, b) => (a !== undefined ? a : b);
21
- const toNum = (v) => (typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined);
27
+ const toNum = (v) => typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined;
22
28
  const info = {
23
- batchId: pick(raw?.batchId, raw?.BatchId),
24
- createdAtUtc: pick(raw?.createdAtUtc, raw?.CreatedAtUtc),
25
- startedAtUtc: pick(raw?.startedAtUtc, raw?.StartedAtUtc),
26
- completedAtUtc: pick(raw?.completedAtUtc, raw?.CompletedAtUtc),
27
- initiatedByUser: pick(raw?.initiatedByUser, raw?.InitiatedByUser),
28
- initiatorSessionId: pick(raw?.initiatorSessionId, raw?.InitiatorSessionId),
29
- provider: pick(raw?.provider, raw?.Provider),
30
- status: pick(raw?.status, raw?.Status),
31
- expectedJobs: toNum(pick(raw?.expectedJobs, raw?.ExpectedJobs)),
32
- completedJobs: toNum(pick(raw?.completedJobs, raw?.CompletedJobs)),
33
- errorJobs: toNum(pick(raw?.errorJobs, raw?.ErrorJobs)),
34
- metadata: pick(raw?.metadata, raw?.Metadata),
35
- lastUpdatedUtc: pick(raw?.lastUpdatedUtc, raw?.LastUpdatedUtc),
36
- exists: pick(raw?.exists, raw?.Exists),
29
+ batchId: raw?.batchId,
30
+ createdAtUtc: raw?.createdAtUtc,
31
+ startedAtUtc: raw?.startedAtUtc,
32
+ completedAtUtc: raw?.completedAtUtc,
33
+ initiatedByUser: raw?.initiatedByUser,
34
+ initiatorSessionId: raw?.initiatorSessionId,
35
+ provider: raw?.provider,
36
+ status: raw?.status,
37
+ expectedJobs: toNum(raw?.expectedJobs),
38
+ completedJobs: toNum(raw?.completedJobs),
39
+ errorJobs: toNum(raw?.errorJobs),
40
+ metadata: raw?.metadata,
41
+ lastUpdatedUtc: raw?.lastUpdatedUtc,
42
+ exists: raw?.exists,
37
43
  };
38
44
  return info.batchId ? info : null;
39
45
  }
40
- export function BatchTranslationView({ batchId, onBack }) {
46
+ function normalizeJobItemId(itemId) {
47
+ return (itemId ?? "").toString().toLowerCase();
48
+ }
49
+ function getJobKey(itemId, language) {
50
+ return `${normalizeJobItemId(itemId)}-${(language ?? "").toString()}`;
51
+ }
52
+ function getSocketPayloadMessage(payload) {
53
+ return payload?.message || payload?.error || "";
54
+ }
55
+ function normalizeJobRecord(raw) {
56
+ return {
57
+ id: typeof raw?.id === "number"
58
+ ? raw.id
59
+ : typeof raw?.Id === "number"
60
+ ? raw.Id
61
+ : undefined,
62
+ itemId: normalizeJobItemId(raw?.itemId ?? raw?.ItemId),
63
+ targetLanguage: raw?.targetLanguage ?? raw?.TargetLanguage ?? "",
64
+ sourceLanguage: raw?.sourceLanguage ?? raw?.SourceLanguage ?? "",
65
+ status: raw?.status ?? raw?.Status ?? "",
66
+ timestamp: raw?.timestamp ?? raw?.Timestamp ?? "",
67
+ hash: raw?.hash ?? raw?.Hash,
68
+ message: raw?.message ?? raw?.Message,
69
+ batchId: raw?.batchId ?? raw?.BatchId,
70
+ metadata: raw?.metadata ?? raw?.Metadata ?? null,
71
+ };
72
+ }
73
+ function getRetryJobKey(job) {
74
+ return `${job.id ?? "no-id"}|${normalizeJobItemId(job.itemId)}|${job.sourceLanguage}|${job.targetLanguage}`;
75
+ }
76
+ export function BatchTranslationView({ batchId, onBack, }) {
41
77
  const editContext = useEditContext();
78
+ const router = useRouter();
79
+ const pathname = usePathname();
80
+ const searchParams = useSearchParams();
42
81
  const [batchJobs, setBatchJobs] = useState([]);
43
82
  const [batchInfo, setBatchInfo] = useState(null);
44
83
  const [providers, setProviders] = useState([]);
45
84
  const [isLoading, setIsLoading] = useState(false);
85
+ const [isRetryingAll, setIsRetryingAll] = useState(false);
86
+ const [retryingJobKeys, setRetryingJobKeys] = useState(new Set());
46
87
  const [translationProgress, setTranslationProgress] = useState(new Map());
47
88
  const [itemNames, setItemNames] = useState(new Map());
48
89
  const [isMetadataExpanded, setIsMetadataExpanded] = useState(false);
@@ -56,10 +97,94 @@ export function BatchTranslationView({ batchId, onBack }) {
56
97
  // Helper function to get display name from service name
57
98
  const getProviderDisplayName = useCallback((serviceName) => {
58
99
  if (!serviceName)
59
- return 'Unknown';
60
- const provider = providers.find(p => p.name === serviceName);
100
+ return "Unknown";
101
+ const provider = providers.find((p) => p.name === serviceName);
61
102
  return provider?.displayName || serviceName;
62
103
  }, [providers]);
104
+ const openItemInEditor = useCallback((itemId, language) => {
105
+ editContext?.loadItem({
106
+ id: normalizeJobItemId(itemId),
107
+ language,
108
+ version: 0,
109
+ });
110
+ editContext?.switchWorkspace?.("editor");
111
+ }, [editContext]);
112
+ const openBatchInView = useCallback((nextBatchId) => {
113
+ const current = new URLSearchParams(searchParams.toString());
114
+ current.set("batchId", nextBatchId);
115
+ router.push(`${pathname}?${current.toString()}`, { scroll: false });
116
+ }, [pathname, router, searchParams]);
117
+ const buildRetryJobs = useCallback((jobs) => {
118
+ const uniqueJobs = new Map();
119
+ for (const job of jobs) {
120
+ if (job.status !== "Error")
121
+ continue;
122
+ const key = getRetryJobKey(job);
123
+ if (uniqueJobs.has(key))
124
+ continue;
125
+ uniqueJobs.set(key, {
126
+ sourceTranslationId: job.id,
127
+ itemId: normalizeJobItemId(job.itemId),
128
+ sourceLanguage: job.sourceLanguage,
129
+ targetLanguage: job.targetLanguage,
130
+ metadata: job.metadata ?? undefined,
131
+ });
132
+ }
133
+ return Array.from(uniqueJobs.values());
134
+ }, []);
135
+ const handleRetryJobs = useCallback(async (jobs, retryAll) => {
136
+ const retryJobs = buildRetryJobs(jobs);
137
+ if (retryJobs.length === 0) {
138
+ toast.error("No failed translations available to retry.");
139
+ return;
140
+ }
141
+ if (!editContext?.sessionId) {
142
+ toast.error("Cannot retry without an active session.");
143
+ return;
144
+ }
145
+ if (!batchInfo?.provider) {
146
+ toast.error("Retry provider could not be resolved for this batch.");
147
+ return;
148
+ }
149
+ if (retryAll) {
150
+ setIsRetryingAll(true);
151
+ }
152
+ else {
153
+ setRetryingJobKeys(new Set(jobs.map((job) => getRetryJobKey(job)).filter((key) => retryJobs.some((retryJob) => key ===
154
+ `${retryJob.sourceTranslationId ?? "no-id"}|${retryJob.itemId}|${retryJob.sourceLanguage}|${retryJob.targetLanguage}`))));
155
+ }
156
+ try {
157
+ const result = await retryBatchTranslation({
158
+ sessionId: editContext.sessionId,
159
+ sourceBatchId: batchId,
160
+ provider: batchInfo.provider,
161
+ jobs: retryJobs,
162
+ });
163
+ if (result.type !== "success" || !result.data) {
164
+ toast.error(result.details ||
165
+ result.summary ||
166
+ "Failed to start retry translations.");
167
+ return;
168
+ }
169
+ const response = result.data;
170
+ const startedCount = response.started?.length ?? 0;
171
+ const skippedCount = response.skipped?.length ?? 0;
172
+ if (startedCount === 0) {
173
+ toast.error(skippedCount > 0
174
+ ? response.skipped[0]?.error || "No retries were started."
175
+ : "No retries were started.");
176
+ return;
177
+ }
178
+ toast.success(retryAll
179
+ ? `Started retry batch with ${startedCount} translation${startedCount === 1 ? "" : "s"}.`
180
+ : "Started retry batch for failed translation.");
181
+ openBatchInView(response.retryBatchId);
182
+ }
183
+ finally {
184
+ setIsRetryingAll(false);
185
+ setRetryingJobKeys(new Set());
186
+ }
187
+ }, [batchId, batchInfo?.provider, buildRetryJobs, editContext?.sessionId, openBatchInView]);
63
188
  // Parse metadata for display
64
189
  const parsedMetadata = useMemo(() => {
65
190
  if (!batchInfo?.metadata)
@@ -78,7 +203,9 @@ export function BatchTranslationView({ batchId, onBack }) {
78
203
  const loadProviders = useCallback(async () => {
79
204
  try {
80
205
  const res = await getTranslationProviders();
81
- const providerData = (res?.data ?? res ?? []);
206
+ const providerData = (res?.data ??
207
+ res ??
208
+ []);
82
209
  setProviders(providerData);
83
210
  }
84
211
  catch (error) {
@@ -90,36 +217,45 @@ export function BatchTranslationView({ batchId, onBack }) {
90
217
  try {
91
218
  // Use efficient batch-specific endpoint
92
219
  const res = await listBatchTranslationJobs(batchId);
93
- const apiJobs = (res?.data ?? res ?? []);
220
+ const apiJobsRaw = (res?.data ?? res ?? []);
221
+ const apiJobs = apiJobsRaw.map(normalizeJobRecord);
94
222
  // Merge API response with existing state to prevent flickering
95
- setBatchJobs(prevJobs => {
223
+ setBatchJobs((prevJobs) => {
96
224
  // Create a map of existing jobs keyed by itemId-targetLanguage
97
225
  const existingMap = new Map();
98
226
  for (const job of prevJobs) {
99
- const key = `${job.itemId}-${job.targetLanguage}`;
227
+ const key = getJobKey(job.itemId, job.targetLanguage);
100
228
  existingMap.set(key, job);
101
229
  }
102
230
  // Merge API jobs into the map (API data takes precedence)
103
231
  for (const apiJob of apiJobs) {
104
- const key = `${apiJob.itemId}-${apiJob.targetLanguage}`;
232
+ const normalizedJob = normalizeJobRecord(apiJob);
233
+ const key = getJobKey(normalizedJob.itemId, normalizedJob.targetLanguage);
105
234
  const existingJob = existingMap.get(key);
106
235
  if (existingJob) {
107
236
  // Prefer API data if timestamp is newer or status changed
108
- const apiTimestamp = apiJob.timestamp ? new Date(apiJob.timestamp).getTime() : 0;
109
- const existingTimestamp = existingJob.timestamp ? new Date(existingJob.timestamp).getTime() : 0;
110
- if (apiTimestamp >= existingTimestamp || apiJob.status !== existingJob.status) {
237
+ const apiTimestamp = normalizedJob.timestamp
238
+ ? new Date(normalizedJob.timestamp).getTime()
239
+ : 0;
240
+ const existingTimestamp = existingJob.timestamp
241
+ ? new Date(existingJob.timestamp).getTime()
242
+ : 0;
243
+ if (apiTimestamp >= existingTimestamp ||
244
+ normalizedJob.status !== existingJob.status) {
111
245
  // Preserve sourceLanguage from API if available, otherwise keep existing
112
246
  existingMap.set(key, {
113
- ...apiJob,
114
- sourceLanguage: apiJob.sourceLanguage || existingJob.sourceLanguage || ""
247
+ ...normalizedJob,
248
+ sourceLanguage: normalizedJob.sourceLanguage ||
249
+ existingJob.sourceLanguage ||
250
+ "",
115
251
  });
116
252
  }
117
253
  }
118
254
  else {
119
255
  // New job from API - ensure sourceLanguage is set
120
256
  existingMap.set(key, {
121
- ...apiJob,
122
- sourceLanguage: apiJob.sourceLanguage || ""
257
+ ...normalizedJob,
258
+ sourceLanguage: normalizedJob.sourceLanguage || "",
123
259
  });
124
260
  }
125
261
  }
@@ -131,7 +267,7 @@ export function BatchTranslationView({ batchId, onBack }) {
131
267
  return mergedJobs;
132
268
  }
133
269
  // Deep comparison: check if any job changed
134
- const prevMap = new Map(prevJobs.map(j => [`${j.itemId}-${j.targetLanguage}`, j]));
270
+ const prevMap = new Map(prevJobs.map((j) => [`${j.itemId}-${j.targetLanguage}`, j]));
135
271
  let hasChanges = false;
136
272
  for (const mergedJob of mergedJobs) {
137
273
  const key = `${mergedJob.itemId}-${mergedJob.targetLanguage}`;
@@ -181,7 +317,7 @@ export function BatchTranslationView({ batchId, onBack }) {
181
317
  const info = normalizeBatchInfo(raw);
182
318
  if (!cancelled && info)
183
319
  setBatchInfo(info);
184
- const isTerminal = info?.status === 'Completed' || info?.status === 'Error';
320
+ const isTerminal = info?.status === "Completed" || info?.status === "Error";
185
321
  // Conditionally subscribe only for non-terminal batches
186
322
  if (!isTerminal && editContext?.sessionId) {
187
323
  await subscribeToBatch(batchId, editContext.sessionId);
@@ -204,7 +340,10 @@ export function BatchTranslationView({ batchId, onBack }) {
204
340
  init();
205
341
  return () => {
206
342
  // Cleanup: unsubscribe when component unmounts or batch changes
207
- if (isSubscribedRef.current && editContext && editContext.sessionId && batchId) {
343
+ if (isSubscribedRef.current &&
344
+ editContext &&
345
+ editContext.sessionId &&
346
+ batchId) {
208
347
  unsubscribeFromBatch(batchId, editContext.sessionId).catch(console.error);
209
348
  }
210
349
  };
@@ -226,8 +365,10 @@ export function BatchTranslationView({ batchId, onBack }) {
226
365
  // Only update if this message is for our batch
227
366
  if (message.payload.batchId === batchId) {
228
367
  // Update the specific job in our local state instead of refetching all jobs
229
- setBatchJobs(prevJobs => {
230
- const jobIndex = prevJobs.findIndex(job => job.itemId === message.payload.itemId &&
368
+ setBatchJobs((prevJobs) => {
369
+ const normalizedItemId = normalizeJobItemId(message.payload.itemId);
370
+ const payloadMessage = getSocketPayloadMessage(message.payload);
371
+ const jobIndex = prevJobs.findIndex((job) => normalizeJobItemId(job.itemId) === normalizedItemId &&
231
372
  job.targetLanguage === message.payload.language);
232
373
  if (jobIndex >= 0) {
233
374
  // Update existing job
@@ -236,41 +377,44 @@ export function BatchTranslationView({ batchId, onBack }) {
236
377
  if (existingJob) {
237
378
  updatedJobs[jobIndex] = {
238
379
  ...existingJob,
380
+ itemId: normalizedItemId,
239
381
  status: message.payload.status,
240
382
  timestamp: message.payload.timestamp || existingJob.timestamp,
241
- message: message.payload.message || existingJob.message,
383
+ message: payloadMessage || existingJob.message,
242
384
  // Preserve sourceLanguage from existing job if not provided in message
243
- sourceLanguage: message.payload.sourceLanguage || existingJob.sourceLanguage || ""
385
+ sourceLanguage: message.payload.sourceLanguage ||
386
+ existingJob.sourceLanguage ||
387
+ "",
244
388
  };
245
389
  }
246
390
  return updatedJobs;
247
391
  }
248
- else if (message.type === "translation-started") {
249
- // Add new job if it doesn't exist (shouldn't happen with batch subscriptions, but safe fallback)
392
+ else {
393
+ // Add new job if it doesn't exist yet so terminal updates are still visible.
250
394
  const newJob = {
251
- itemId: message.payload.itemId,
395
+ itemId: normalizedItemId,
252
396
  targetLanguage: message.payload.language,
253
397
  sourceLanguage: message.payload.sourceLanguage || "", // Keep this, but we'll try to populate from API
254
398
  status: message.payload.status,
255
399
  timestamp: message.payload.timestamp || new Date().toISOString(),
256
400
  batchId: batchId,
257
- message: message.payload.message || ""
401
+ message: payloadMessage,
258
402
  };
259
403
  return [...prevJobs, newJob];
260
404
  }
261
- return prevJobs;
262
405
  });
263
406
  }
264
407
  }
265
408
  if (message.type === "translation-progress") {
266
409
  // Only track progress for jobs in this batch
267
410
  if (message.payload.batchId === batchId) {
268
- const itemId = (message.payload.itemId ?? "").toString().toLowerCase();
411
+ const itemId = normalizeJobItemId(message.payload.itemId);
269
412
  const language = message.payload.language;
270
- const progressKey = `${itemId}-${language}`;
413
+ const progressKey = getJobKey(itemId, language);
271
414
  // Ensure a row exists; if not, add a placeholder "In Progress" job
272
- setBatchJobs(prevJobs => {
273
- const idx = prevJobs.findIndex(j => j.itemId === itemId && j.targetLanguage === language);
415
+ setBatchJobs((prevJobs) => {
416
+ const idx = prevJobs.findIndex((j) => normalizeJobItemId(j.itemId) === itemId &&
417
+ j.targetLanguage === language);
274
418
  if (idx >= 0)
275
419
  return prevJobs;
276
420
  const newJob = {
@@ -280,7 +424,7 @@ export function BatchTranslationView({ batchId, onBack }) {
280
424
  status: "In Progress",
281
425
  timestamp: new Date().toISOString(),
282
426
  batchId: batchId,
283
- message: message.payload.message || ""
427
+ message: message.payload.message || "",
284
428
  };
285
429
  return [...prevJobs, newJob];
286
430
  });
@@ -288,14 +432,15 @@ export function BatchTranslationView({ batchId, onBack }) {
288
432
  const next = new Map(prev);
289
433
  next.set(progressKey, {
290
434
  progress: message.payload.progress || 0,
291
- message: message.payload.message || ""
435
+ message: message.payload.message || "",
292
436
  });
293
437
  return next;
294
438
  });
295
439
  }
296
440
  }
297
441
  // Handle batch-completed messages for this specific batch
298
- if (message.type === "batch-completed" && message.payload.batchId === batchId) {
442
+ if (message.type === "batch-completed" &&
443
+ message.payload.batchId === batchId) {
299
444
  // Refresh header info only (jobs are already updated via websocket)
300
445
  (async () => {
301
446
  try {
@@ -318,7 +463,7 @@ export function BatchTranslationView({ batchId, onBack }) {
318
463
  }, [editContext, batchId]);
319
464
  // Adaptive polling while batch is in progress - only as fallback when websockets are silent
320
465
  useEffect(() => {
321
- const isRunning = batchInfo?.status === 'In Progress';
466
+ const isRunning = batchInfo?.status === "In Progress";
322
467
  if (!isRunning)
323
468
  return;
324
469
  const pollBatchInfo = async () => {
@@ -356,7 +501,7 @@ export function BatchTranslationView({ batchId, onBack }) {
356
501
  }
357
502
  const fetchItemNames = async () => {
358
503
  // Extract unique item IDs from batchJobs
359
- const uniqueItemIds = Array.from(new Set(batchJobs.map(job => job.itemId)));
504
+ const uniqueItemIds = Array.from(new Set(batchJobs.map((job) => job.itemId)));
360
505
  if (uniqueItemIds.length === 0) {
361
506
  return;
362
507
  }
@@ -364,7 +509,7 @@ export function BatchTranslationView({ batchId, onBack }) {
364
509
  const language = editContext.currentItemDescriptor?.language || "en";
365
510
  try {
366
511
  // Create ItemDescriptors for fetching
367
- const itemDescriptors = uniqueItemIds.map(itemId => ({
512
+ const itemDescriptors = uniqueItemIds.map((itemId) => ({
368
513
  id: itemId,
369
514
  language: language,
370
515
  version: 0, // Latest version
@@ -378,7 +523,7 @@ export function BatchTranslationView({ batchId, onBack }) {
378
523
  namesMap.set(stub.id.toLowerCase(), stub.name);
379
524
  }
380
525
  }
381
- setItemNames(prevNames => {
526
+ setItemNames((prevNames) => {
382
527
  // Merge with existing names, only update if there are changes
383
528
  let hasChanges = false;
384
529
  const merged = new Map(prevNames);
@@ -397,7 +542,11 @@ export function BatchTranslationView({ batchId, onBack }) {
397
542
  }
398
543
  };
399
544
  fetchItemNames();
400
- }, [batchJobs, editContext?.itemsRepository, editContext?.currentItemDescriptor?.language]);
545
+ }, [
546
+ batchJobs,
547
+ editContext?.itemsRepository,
548
+ editContext?.currentItemDescriptor?.language,
549
+ ]);
401
550
  // Group jobs by item for better organization
402
551
  const itemGroups = useMemo(() => {
403
552
  const groups = {};
@@ -415,9 +564,9 @@ export function BatchTranslationView({ batchId, onBack }) {
415
564
  return entries.sort(([, jobsA], [, jobsB]) => {
416
565
  // Calculate status priority for each item group
417
566
  const getStatusPriority = (jobs) => {
418
- const hasInProgress = jobs.some(j => j.status === "In Progress");
419
- const hasError = jobs.some(j => j.status === "Error");
420
- const allCompleted = jobs.every(j => j.status === "Completed");
567
+ const hasInProgress = jobs.some((j) => j.status === "In Progress");
568
+ const hasError = jobs.some((j) => j.status === "Error");
569
+ const allCompleted = jobs.every((j) => j.status === "Completed");
421
570
  if (hasInProgress)
422
571
  return 1; // Highest priority - show first
423
572
  if (hasError)
@@ -434,7 +583,7 @@ export function BatchTranslationView({ batchId, onBack }) {
434
583
  // Calculate overall progress segments including in-progress job progress.
435
584
  // Incorporate server-side expected/completed/error counts so pending server jobs are reflected.
436
585
  const progressSegments = useMemo(() => {
437
- const expectedTotal = typeof batchInfo?.expectedJobs === 'number' && batchInfo.expectedJobs > 0
586
+ const expectedTotal = typeof batchInfo?.expectedJobs === "number" && batchInfo.expectedJobs > 0
438
587
  ? batchInfo.expectedJobs
439
588
  : batchJobs.length;
440
589
  if (expectedTotal === 0) {
@@ -448,18 +597,22 @@ export function BatchTranslationView({ batchId, onBack }) {
448
597
  errorCount: 0,
449
598
  pendingCount: 0,
450
599
  total: 0,
451
- actualProgress: 0
600
+ actualProgress: 0,
452
601
  };
453
602
  }
454
- const localCompleted = batchJobs.filter(job => job.status === 'Completed').length;
455
- const localErrors = batchJobs.filter(job => job.status === 'Error').length;
456
- const serverCompleted = typeof batchInfo?.completedJobs === 'number' ? batchInfo.completedJobs : undefined;
457
- const serverErrors = typeof batchInfo?.errorJobs === 'number' ? batchInfo.errorJobs : undefined;
603
+ const localCompleted = batchJobs.filter((job) => job.status === "Completed").length;
604
+ const localErrors = batchJobs.filter((job) => job.status === "Error").length;
605
+ const serverCompleted = typeof batchInfo?.completedJobs === "number"
606
+ ? batchInfo.completedJobs
607
+ : undefined;
608
+ const serverErrors = typeof batchInfo?.errorJobs === "number"
609
+ ? batchInfo.errorJobs
610
+ : undefined;
458
611
  // Prefer the larger of local/server so UI can progress smoothly with local updates
459
612
  const completedCount = Math.max(0, Math.min(expectedTotal, Math.max(localCompleted, serverCompleted ?? 0)));
460
613
  const errorCount = Math.max(0, Math.min(expectedTotal - completedCount, Math.max(localErrors, serverErrors ?? 0)));
461
614
  // Known in-progress from client state
462
- const knownInProgressCount = batchJobs.filter(job => job.status === 'In Progress').length;
615
+ const knownInProgressCount = batchJobs.filter((job) => job.status === "In Progress").length;
463
616
  // Cap to not exceed remaining after completed+error
464
617
  const inProgressCount = Math.max(0, Math.min(knownInProgressCount, expectedTotal - completedCount - errorCount));
465
618
  const pendingCount = Math.max(0, expectedTotal - completedCount - errorCount - inProgressCount);
@@ -467,8 +620,8 @@ export function BatchTranslationView({ batchId, onBack }) {
467
620
  // Actual progress: completed=100 each, in-progress use live progress if available, others 0
468
621
  let totalActualProgress = completedCount * 100;
469
622
  for (const job of batchJobs) {
470
- if (job.status === 'In Progress') {
471
- const progressKey = `${job.itemId}-${job.targetLanguage}`;
623
+ if (job.status === "In Progress") {
624
+ const progressKey = getJobKey(job.itemId, job.targetLanguage);
472
625
  const jobProgress = translationProgress.get(progressKey);
473
626
  totalActualProgress += jobProgress?.progress || 0;
474
627
  }
@@ -489,6 +642,7 @@ export function BatchTranslationView({ batchId, onBack }) {
489
642
  return segments;
490
643
  }, [batchJobs, batchInfo, translationProgress]);
491
644
  const overallProgress = progressSegments.actualProgress;
645
+ const isMobile = editContext?.isMobile ?? false;
492
646
  // Treat the batch as Completed if all jobs are completed and there are no errors/in-progress,
493
647
  // even if the backend status is still "In Progress".
494
648
  const effectiveStatus = batchInfo?.status === "In Progress" &&
@@ -517,14 +671,38 @@ export function BatchTranslationView({ batchId, onBack }) {
517
671
  catch { }
518
672
  })();
519
673
  }, [effectiveStatus, batchId, loadBatchJobs]);
520
- return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-[var(--color-gray-5)]", "data-testid": "batch-translation-view", children: [_jsxs("div", { className: "flex-shrink-0 border-b border-[var(--color-gray-3)] bg-background p-4 md:p-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [onBack && (_jsxs(Button, { size: "sm", variant: "outline", onClick: onBack, children: [_jsx("i", { className: "pi pi-arrow-left" }), "Back"] })), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold text-[var(--color-dark)]", children: "Translation Batch" }), _jsxs("p", { className: "text-sm text-[var(--color-gray-2)] mt-1", children: ["Batch ID: ", _jsx("span", { className: "font-mono text-xs bg-[var(--color-gray-4)] px-2 py-0.5 rounded-md", children: batchId })] }), batchInfo && (_jsxs("div", { className: "mt-2 flex flex-wrap gap-2 items-center text-xs", children: [effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center rounded-full px-2.5 py-1 font-medium ${effectiveStatus === 'Completed'
521
- ? 'bg-green-100 text-green-700'
522
- : effectiveStatus === 'In Progress'
523
- ? ''
524
- : effectiveStatus === 'Error'
525
- ? 'bg-red-100 text-red-600'
526
- : 'bg-[var(--color-gray-4)] text-[var(--color-gray-1)]'}`, style: effectiveStatus === 'In Progress' ? { backgroundColor: '#f6eeff', color: '#9650fb' } : undefined, children: effectiveStatus })), batchInfo.provider && (_jsxs("span", { className: "text-[var(--color-gray-1)]", children: ["Provider: ", _jsx("span", { className: "font-medium", children: getProviderDisplayName(batchInfo.provider) })] })), batchInfo.initiatedByUser && (_jsxs("span", { className: "text-[var(--color-gray-1)]", children: ["By: ", _jsx("span", { className: "font-medium", children: batchInfo.initiatedByUser })] })), (batchInfo.startedAtUtc || batchInfo.completedAtUtc || batchInfo.lastUpdatedUtc) && (_jsxs("span", { className: "text-[var(--color-gray-2)]", children: [batchInfo.startedAtUtc && (_jsxs(_Fragment, { children: ["Started: ", new Date(batchInfo.startedAtUtc).toLocaleString()] })), batchInfo.completedAtUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? ' • ' : '', "Completed: ", new Date(batchInfo.completedAtUtc).toLocaleString()] })), !batchInfo.completedAtUtc && batchInfo.lastUpdatedUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? ' • ' : '', "Updated: ", new Date(batchInfo.lastUpdatedUtc).toLocaleString()] }))] }))] }))] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("div", { className: "text-sm text-[var(--color-gray-2)]", children: [`${progressSegments.completedCount}/${progressSegments.total} completed`, progressSegments.errorCount ? `, ${progressSegments.errorCount} errors` : ''] }), _jsxs(Button, { size: "sm", variant: "outline", onClick: loadBatchJobs, disabled: isLoading, title: "Manual refresh - normally updates come through websocket subscriptions", children: [_jsx("i", { className: `pi pi-refresh ${isLoading ? 'pi-spin' : ''}` }), "Refresh"] })] })] }), parsedMetadata && (_jsxs("div", { className: "border-t border-[var(--color-gray-3)] bg-[var(--color-gray-5)] mt-4 rounded-b-lg", children: [_jsxs("button", { onClick: () => setIsMetadataExpanded(!isMetadataExpanded), className: "flex w-full cursor-pointer items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-gray-4)]", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("i", { className: "pi pi-info-circle text-[var(--color-gray-2)]" }), _jsx("span", { className: "text-xs font-medium text-[var(--color-gray-1)]", children: "Batch Metadata" })] }), isMetadataExpanded ? (_jsx(ChevronUpIcon, { className: "h-4 w-4 text-[var(--color-gray-2)]", strokeWidth: 1 })) : (_jsx(ChevronDownIcon, { className: "h-4 w-4 text-[var(--color-gray-2)]", strokeWidth: 1 }))] }), isMetadataExpanded && (_jsx("div", { className: "max-h-96 overflow-y-auto px-4 pb-3", children: _jsx("div", { className: "rounded-lg border border-[var(--color-gray-3)] bg-background p-3 text-xs shadow-sm mt-2", children: _jsx(MemoizedJsonView, { data: parsedMetadata }) }) }))] })), _jsxs("div", { className: "mt-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-medium text-[var(--color-dark)]", children: "Overall Progress" }), _jsxs("span", { className: "text-sm text-[var(--color-gray-2)]", children: [progressSegments.completedCount, " / ", progressSegments.total, " completed"] })] }), _jsxs("div", { className: "relative h-4 rounded-full overflow-hidden", style: { backgroundColor: 'var(--color-gray-3)' }, children: [_jsxs("div", { className: "absolute inset-0 flex", children: [progressSegments.completed > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: { width: `${progressSegments.completed}%`, backgroundColor: '#8ae048' } })), progressSegments.inProgress > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: { width: `${progressSegments.inProgress}%`, backgroundColor: '#9650fb' } })), progressSegments.error > 0 && (_jsx("div", { className: "bg-destructive h-full transition-all duration-300", style: { width: `${progressSegments.error}%` } })), progressSegments.pending > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: { width: `${progressSegments.pending}%`, backgroundColor: 'var(--color-gray-4)' } }))] }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: _jsxs("span", { className: "text-xs font-medium text-white drop-shadow-sm", children: [overallProgress, "%"] }) })] }), _jsxs("div", { className: "flex flex-wrap gap-3 mt-2 text-xs", children: [progressSegments.completedCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 rounded-sm", style: { backgroundColor: '#8ae048' } }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.completedCount, " completed"] })] })), progressSegments.inProgressCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 rounded-sm", style: { backgroundColor: '#9650fb' } }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.inProgressCount, " in progress"] })] })), progressSegments.errorCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 bg-destructive rounded-sm" }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.errorCount, " errors"] })] })), progressSegments.pendingCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 bg-[var(--color-gray-4)] rounded-sm" }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [progressSegments.pendingCount, " pending"] })] })), typeof batchInfo?.expectedJobs === 'number' && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-3 h-3 bg-[var(--color-gray-4)] rounded-sm" }), _jsxs("span", { className: "text-[var(--color-gray-1)]", children: [(batchInfo.expectedJobs || 0) - (batchInfo.completedJobs || 0) - (batchInfo.errorJobs || 0), " pending (server)"] })] }))] })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-[var(--color-gray-2)]", children: [_jsx("i", { className: "pi pi-spin pi-spinner text-[#9650fb]" }), "Loading batch translations..."] }) })) : batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-[var(--color-gray-2)]", children: [_jsx("i", { className: "pi pi-language text-2xl block mb-2 text-[var(--color-gray-3)]" }), _jsx("p", { className: "font-medium text-[var(--color-gray-1)]", children: "No translations found for this batch" }), _jsx("p", { className: "text-sm mt-1", children: "The batch may not exist or no translation jobs have started yet" })] }) })) : (_jsx("div", { className: "space-y-4", children: sortedItemGroups.map(([itemId, jobs]) => {
527
- const itemCompleted = jobs.filter(j => j.status === "Completed").length;
674
+ return (_jsxs("div", { className: "flex h-full flex-col min-h-0 bg-gray-5", "data-testid": "batch-translation-view", children: [_jsxs("div", { className: "shrink-0 border-b border-gray-3 bg-background p-4 md:p-6", children: [_jsxs("div", { className: `flex ${isMobile ? "flex-col items-start gap-4" : "items-center justify-between"}`, children: [_jsxs("div", { className: `flex ${isMobile ? "w-full" : "items-center"} gap-3`, children: [onBack && (_jsxs(Button, { size: "sm", variant: "outline", onClick: onBack, className: isMobile ? "shrink-0 self-start p-0 w-8 h-8" : "", children: [_jsx(ArrowLeftIcon, { className: "h-4 w-4" }), isMobile ? "" : "Back"] })), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-lg md:text-xl font-semibold text-(--color-dark) truncate", children: "Translation Batch" }), _jsxs("p", { className: "text-xs md:text-sm text-gray-2 mt-0.5 md:mt-1", children: ["Batch ID:", " ", _jsx("span", { className: "font-mono text-[10px] md:text-xs bg-gray-4 px-2 py-0.5 rounded-md break-all", children: batchId })] }), batchInfo && !isMobile && (_jsxs("div", { className: "mt-2 flex flex-wrap gap-2 items-center text-xs", children: [effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center rounded-full px-2.5 py-1 font-medium ${effectiveStatus === "Completed"
675
+ ? "bg-green-100 text-green-700"
676
+ : effectiveStatus === "In Progress"
677
+ ? ""
678
+ : effectiveStatus === "Error"
679
+ ? "bg-red-100 text-red-600"
680
+ : "bg-gray-4 text-(--color-gray-1)"}`, style: effectiveStatus === "In Progress"
681
+ ? { backgroundColor: "#f6eeff", color: "#9650fb" }
682
+ : undefined, children: effectiveStatus })), batchInfo.provider && (_jsxs("span", { className: "text-(--color-gray-1)", children: ["Provider:", " ", _jsx("span", { className: "font-medium", children: getProviderDisplayName(batchInfo.provider) })] })), batchInfo.initiatedByUser && (_jsxs("span", { className: "text-(--color-gray-1)", children: ["By:", " ", _jsx("span", { className: "font-medium", children: batchInfo.initiatedByUser })] })), (batchInfo.startedAtUtc ||
683
+ batchInfo.completedAtUtc ||
684
+ batchInfo.lastUpdatedUtc) && (_jsxs("span", { className: "text-gray-2", children: [batchInfo.startedAtUtc && (_jsxs(_Fragment, { children: ["Started:", " ", new Date(batchInfo.startedAtUtc).toLocaleString()] })), batchInfo.completedAtUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? " • " : "", "Completed:", " ", new Date(batchInfo.completedAtUtc).toLocaleString()] })), !batchInfo.completedAtUtc &&
685
+ batchInfo.lastUpdatedUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? " • " : "", "Updated:", " ", new Date(batchInfo.lastUpdatedUtc).toLocaleString()] }))] }))] }))] })] }), isMobile && batchInfo && (_jsxs("div", { className: "grid grid-cols-2 gap-x-4 gap-y-3 w-full mt-1 border-t border-gray-3 pt-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Status" }), effectiveStatus && (_jsx("span", { "data-testid": "translation-job-status", className: `inline-flex items-center self-start rounded-full px-2 py-0.5 text-[10px] font-medium ${effectiveStatus === "Completed"
686
+ ? "bg-green-100 text-green-700"
687
+ : effectiveStatus === "In Progress"
688
+ ? ""
689
+ : effectiveStatus === "Error"
690
+ ? "bg-red-100 text-red-600"
691
+ : "bg-gray-4 text-(--color-gray-1)"}`, style: effectiveStatus === "In Progress"
692
+ ? { backgroundColor: "#f6eeff", color: "#9650fb" }
693
+ : undefined, children: effectiveStatus }))] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Provider" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1) truncate", children: getProviderDisplayName(batchInfo.provider) })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Initiated By" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1) truncate", children: batchInfo.initiatedByUser || "System" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 uppercase font-bold tracking-wider", children: "Activity" }), _jsx("span", { className: "text-[10px] text-gray-2 leading-tight", children: batchInfo.completedAtUtc ? (_jsxs(_Fragment, { children: ["Done:", " ", new Date(batchInfo.completedAtUtc).toLocaleTimeString()] })) : batchInfo.startedAtUtc ? (_jsxs(_Fragment, { children: ["Started:", " ", new Date(batchInfo.startedAtUtc).toLocaleTimeString()] })) : batchInfo.lastUpdatedUtc ? (_jsxs(_Fragment, { children: ["Updated:", " ", new Date(batchInfo.lastUpdatedUtc).toLocaleTimeString()] })) : (_jsx(_Fragment, { children: "Created" })) })] })] })), _jsxs("div", { className: `flex items-center ${isMobile ? "w-full justify-between border-t border-gray-3 pt-3 mt-1" : "gap-3"}`, children: [_jsxs("div", { className: "text-xs md:text-sm text-gray-2", children: [_jsx("span", { className: "font-semibold text-(--color-dark)", children: progressSegments.completedCount }), "/", progressSegments.total, " completed", progressSegments.errorCount
694
+ ? `, ${progressSegments.errorCount} errors`
695
+ : ""] }), _jsxs("div", { className: "flex items-center gap-2", children: [progressSegments.errorCount > 0 && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void handleRetryJobs(batchJobs.filter((job) => job.status === "Error"), true), disabled: isRetryingAll || isLoading, className: "md:w-auto h-8 px-3", children: [_jsx(RefreshIcon, { className: `h-4 w-4 ${isRetryingAll ? "animate-spin" : ""}` }), _jsx("span", { children: "Retry All Failed" })] })), _jsxs(Button, { size: "sm", variant: "outline", onClick: loadBatchJobs, disabled: isLoading || isRetryingAll, title: "Manual refresh - normally updates come through websocket subscriptions", className: "md:w-auto w-8 h-8 md:h-8 p-0 md:px-3", children: [_jsx(RefreshIcon, { className: `h-4 w-4 ${isLoading ? "animate-spin" : ""}` }), _jsx("span", { className: "hidden md:inline", children: "Refresh" })] })] })] })] }), parsedMetadata && (_jsxs("div", { className: `border-t border-gray-3 bg-gray-5 mt-4 rounded-b-lg ${isMobile ? "-mx-4 border-x-0" : ""}`, children: [_jsxs("button", { onClick: () => setIsMetadataExpanded(!isMetadataExpanded), className: `flex w-full cursor-pointer items-center justify-between ${isMobile ? "px-4 py-3" : "px-4 py-2.5"} text-left transition-colors hover:bg-gray-4`, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(InfoIcon, { className: "h-4 w-4 text-gray-2" }), _jsx("span", { className: "text-xs font-medium text-(--color-gray-1)", children: "Batch Metadata" })] }), isMetadataExpanded ? (_jsx(ChevronUpIcon, { className: "h-4 w-4 text-gray-2", strokeWidth: 1 })) : (_jsx(ChevronDownIcon, { className: "h-4 w-4 text-gray-2", strokeWidth: 1 }))] }), isMetadataExpanded && (_jsx("div", { className: "max-h-96 overflow-y-auto px-4 pb-3", children: _jsx("div", { className: "rounded-lg border border-gray-3 bg-background p-3 text-xs shadow-sm mt-2", children: _jsx(MemoizedJsonView, { data: parsedMetadata }) }) }))] })), _jsxs("div", { className: "mt-6 md:mt-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-medium text-(--color-dark)", children: "Overall Progress" }), _jsxs("span", { className: "text-sm text-gray-2", children: [_jsx("span", { className: "font-semibold text-(--color-dark)", children: progressSegments.completedCount }), " ", "/ ", progressSegments.total, " completed"] })] }), _jsxs("div", { className: `relative ${isMobile ? "h-5" : "h-4"} rounded-full overflow-hidden bg-gray-3`, children: [_jsxs("div", { className: "absolute inset-0 flex", children: [progressSegments.completed > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
696
+ width: `${progressSegments.completed}%`,
697
+ backgroundColor: "#8ae048",
698
+ } })), progressSegments.inProgress > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
699
+ width: `${progressSegments.inProgress}%`,
700
+ backgroundColor: "#9650fb",
701
+ } })), progressSegments.error > 0 && (_jsx("div", { className: "bg-destructive h-full transition-all duration-300", style: { width: `${progressSegments.error}%` } })), progressSegments.pending > 0 && (_jsx("div", { className: "h-full transition-all duration-300", style: {
702
+ width: `${progressSegments.pending}%`,
703
+ backgroundColor: "var(--color-gray-4)",
704
+ } }))] }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center pointer-events-none", children: _jsxs("span", { className: `text-xs font-bold text-white drop-shadow-sm ${isMobile ? "-mt-1" : ""}`, children: [overallProgress, "%"] }) })] }), _jsxs("div", { className: `flex flex-wrap ${isMobile ? "gap-x-4 gap-y-2" : "gap-3"} mt-3 md:mt-2 text-[10px] md:text-xs`, children: [progressSegments.completedCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 rounded-sm", style: { backgroundColor: "#8ae048" } }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.completedCount, " completed"] })] })), progressSegments.inProgressCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 rounded-sm", style: { backgroundColor: "#9650fb" } }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.inProgressCount, " in progress"] })] })), progressSegments.errorCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 bg-destructive rounded-sm" }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.errorCount, " errors"] })] })), progressSegments.pendingCount > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "w-2.5 h-2.5 bg-gray-4 rounded-sm" }), _jsxs("span", { className: "text-(--color-gray-1)", children: [progressSegments.pendingCount, " pending"] })] }))] })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 md:p-6 min-h-0", children: isLoading && batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-gray-2", children: [_jsx(LoaderIcon, { className: "h-5 w-5 animate-spin text-theme-secondary" }), "Loading batch translations..."] }) })) : batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-gray-2", children: [_jsx(LanguagesIcon, { className: "h-8 w-8 block mx-auto mb-2 text-gray-2" }), _jsx("p", { className: "font-medium text-(--color-gray-1)", children: "No translations found for this batch" }), _jsx("p", { className: "text-sm mt-1", children: "The batch may not exist or no translation jobs have started yet" })] }) })) : (_jsx("div", { className: "space-y-4", children: sortedItemGroups.map(([itemId, jobs]) => {
705
+ const itemCompleted = jobs.filter((j) => j.status === "Completed").length;
528
706
  // Calculate item progress including in-progress job progress
529
707
  // Performance: item-level calculation is always fast (typically 2-10 jobs per item)
530
708
  let totalProgress = 0;
@@ -534,7 +712,7 @@ export function BatchTranslationView({ batchId, onBack }) {
534
712
  }
535
713
  else if (job.status === "In Progress") {
536
714
  // Get progress for this specific job
537
- const progressKey = `${job.itemId}-${job.targetLanguage}`;
715
+ const progressKey = getJobKey(job.itemId, job.targetLanguage);
538
716
  const jobProgress = translationProgress.get(progressKey);
539
717
  totalProgress += jobProgress?.progress || 0; // Use actual progress or 0
540
718
  }
@@ -543,51 +721,131 @@ export function BatchTranslationView({ batchId, onBack }) {
543
721
  }
544
722
  }
545
723
  const itemProgress = Math.round(totalProgress / jobs.length);
546
- const itemInProgress = jobs.some(j => j.status === "In Progress");
547
- const itemError = jobs.some(j => j.status === "Error");
548
- return (_jsxs("div", { className: "border border-[var(--color-gray-3)] rounded-lg bg-background p-4 md:p-6 shadow-sm", children: [_jsxs("div", { className: "mb-4", children: [_jsx("div", { className: "mb-3", children: (() => {
724
+ const itemInProgress = jobs.some((j) => j.status === "In Progress");
725
+ const itemError = jobs.some((j) => j.status === "Error");
726
+ const itemTerminalError = itemError && !itemInProgress;
727
+ const itemVisualProgress = itemTerminalError ? 100 : itemProgress;
728
+ return (_jsxs("div", { className: `border border-gray-3 rounded-lg bg-background ${isMobile ? "p-4" : "p-6"} shadow-sm`, children: [_jsxs("div", { className: "mb-4", children: [_jsx("div", { className: "mb-3", children: (() => {
549
729
  const itemName = itemNames.get(itemId.toLowerCase());
550
- return itemName ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-base font-semibold text-[var(--color-dark)] mb-1", children: itemName }), _jsxs("div", { className: "text-xs text-[var(--color-gray-2)] font-mono", children: ["Item ID: ", itemId] })] })) : (_jsxs("div", { className: "text-xs text-[var(--color-gray-2)] font-mono", children: ["Item ID: ", itemId] }));
551
- })() }), _jsx("div", { className: "flex items-center justify-between mb-2", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-sm text-[var(--color-gray-2)]", children: [itemCompleted, " / ", jobs.length, " languages"] }), itemInProgress && (_jsx("span", { className: "text-xs px-2.5 py-1 rounded-full font-medium", style: { backgroundColor: '#f6eeff', color: '#9650fb' }, children: "In Progress" })), itemError && (_jsx("span", { className: "text-xs bg-red-100 text-red-600 px-2.5 py-1 rounded-full font-medium", children: "Error" }))] }) }), _jsx("div", { className: "relative", children: _jsx(Progress, { value: itemProgress, className: "h-3 mb-2", indicatorClassName: itemError ? "bg-destructive" : undefined, indicatorStyle: itemError ? undefined :
552
- itemProgress === 100 ? { backgroundColor: '#8ae048' } :
553
- { backgroundColor: '#9650fb' }, showValue: true }) })] }), _jsx(SimpleTable, { columns: [
730
+ return itemName ? (_jsxs(_Fragment, { children: [_jsx("div", { className: `${isMobile ? "text-sm" : "text-base"} font-bold text-(--color-dark) mb-1`, children: itemName }), _jsxs("div", { className: `flex items-center gap-1.5 text-[10px] md:text-xs text-gray-2 font-mono break-all`, children: [_jsxs("span", { className: "break-all", children: ["Item ID: ", itemId] }), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(itemId, "en"), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: "Open item in English", className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] })] })) : (_jsxs("div", { className: `flex items-center gap-1.5 text-[10px] md:text-xs text-gray-2 font-mono break-all`, children: [_jsxs("span", { className: "break-all", children: ["Item ID: ", itemId] }), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(itemId, "en"), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: "Open item in English", className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
731
+ })() }), _jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: `${isMobile ? "text-xs" : "text-sm"} text-gray-2`, children: [itemCompleted, " / ", jobs.length, " languages"] }), itemInProgress && (_jsx("span", { className: `${isMobile ? "text-[10px]" : "text-xs"} px-2 py-0.5 md:px-2.5 md:py-1 rounded-full font-medium`, style: {
732
+ backgroundColor: "#f6eeff",
733
+ color: "#9650fb",
734
+ }, children: "In Progress" })), itemError && (_jsx("span", { className: `${isMobile ? "text-[10px]" : "text-xs"} bg-red-100 text-red-600 px-2 py-0.5 md:px-2.5 md:py-1 rounded-full font-medium`, children: "Error" }))] }), _jsxs("span", { className: `${isMobile ? "text-xs" : "text-sm"} font-bold text-(--color-dark)`, children: [itemVisualProgress, "%"] })] }), _jsx("div", { className: "relative", children: _jsx(Progress, { value: itemVisualProgress, className: `${isMobile ? "h-2" : "h-3"} mb-2`, indicatorClassName: itemError ? "bg-destructive" : undefined, indicatorStyle: itemError
735
+ ? undefined
736
+ : itemProgress === 100
737
+ ? { backgroundColor: "#8ae048" }
738
+ : { backgroundColor: "#9650fb" }, showValue: false }) })] }), isMobile ? (_jsx("div", { className: "space-y-3", children: jobs
739
+ .sort((a, b) => {
740
+ const getJobPriority = (job) => {
741
+ if (job.status === "In Progress")
742
+ return 1;
743
+ if (job.status === "Error")
744
+ return 2;
745
+ if (job.status === "Completed")
746
+ return 4;
747
+ return 3;
748
+ };
749
+ const priorityA = getJobPriority(a);
750
+ const priorityB = getJobPriority(b);
751
+ if (priorityA === priorityB) {
752
+ return a.targetLanguage.localeCompare(b.targetLanguage);
753
+ }
754
+ return priorityA - priorityB;
755
+ })
756
+ .map((job) => {
757
+ const progressKey = getJobKey(job.itemId, job.targetLanguage);
758
+ const progress = translationProgress.get(progressKey);
759
+ const date = new Date(job.timestamp);
760
+ const displayMessage = job.message ||
761
+ (job.status === "Error"
762
+ ? progress?.message || "Translation failed"
763
+ : "");
764
+ const jobRetryKey = getRetryJobKey(job);
765
+ const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
766
+ return (_jsxs("div", { className: "rounded-md border border-gray-4 bg-gray-5 p-3", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-bold text-(--color-dark)", children: job.targetLanguage }), _jsx("span", { className: "text-gray-2", children: "\u2190" }), _jsx("span", { className: "text-xs text-(--color-gray-1)", children: job.sourceLanguage || "—" })] }), _jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold ${job.status === "Completed"
767
+ ? "bg-green-100 text-green-700"
768
+ : job.status === "In Progress"
769
+ ? ""
770
+ : job.status === "Error"
771
+ ? "bg-red-100 text-red-600"
772
+ : "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
773
+ ? {
774
+ backgroundColor: "#f6eeff",
775
+ color: "#9650fb",
776
+ }
777
+ : undefined, children: job.status })] }), displayMessage && (_jsxs("div", { className: "mb-2 flex items-start gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1 text-xs text-gray-2 break-words [overflow-wrap:anywhere] whitespace-normal leading-relaxed line-clamp-3", title: displayMessage, children: displayMessage }), _jsx(CopyButton, { textToCopy: displayMessage, iconOnly: true, className: "mt-0.5 shrink-0 text-gray-2 hover:text-(--color-gray-1)" })] })), job.status === "In Progress" && progress && (_jsxs("div", { className: "mb-2", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-[10px] text-gray-2 truncate pr-2", children: progress.message }), _jsxs("span", { className: "text-[10px] font-bold text-(--color-gray-1) shrink-0", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: {
778
+ backgroundColor: "#9650fb",
779
+ }, showValue: false })] })), job.status === "Error" && (_jsx("div", { className: "mb-2", children: _jsx(Progress, { value: 100, className: "h-2", indicatorClassName: "bg-destructive", showValue: false }) })), _jsx("div", { className: "text-[10px] text-gray-2 text-right mt-1", children: date.toLocaleTimeString([], {
780
+ hour: "2-digit",
781
+ minute: "2-digit",
782
+ }) }), _jsx("div", { className: "mt-2 flex justify-end", children: _jsxs("div", { className: "flex items-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: () => void handleRetryJobs([job], false), icon: _jsx(RefreshIcon, { className: `h-3.5 w-3.5 ${isRetryingJob ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Retry translation as new batch", disabled: isRetryingJob, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(job.itemId, job.targetLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Open item in ${job.targetLanguage}`, disabled: isRetryingAll, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }) })] }, job.targetLanguage));
783
+ }) })) : (_jsx(SimpleTable, { columns: [
554
784
  {
555
785
  header: "Language",
556
786
  className: "w-16", // Fixed width for language column
557
- body: (job) => (_jsx("div", { className: "font-medium text-[var(--color-dark)]", children: job.targetLanguage }))
787
+ body: (job) => (_jsx("div", { className: "font-medium text-(--color-dark)", children: job.targetLanguage })),
558
788
  },
559
789
  {
560
790
  header: "Status",
561
- className: "w-32", // Fixed width for status column
791
+ className: "w-[14rem] min-w-[14rem]", // Keep status column width stable as content changes
562
792
  body: (job) => {
563
- const progressKey = `${job.itemId}-${job.targetLanguage}`;
793
+ const progressKey = getJobKey(job.itemId, job.targetLanguage);
564
794
  const progress = translationProgress.get(progressKey);
565
- return (_jsxs("div", { children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${job.status === "Completed"
795
+ const displayMessage = job.message ||
796
+ (job.status === "Error"
797
+ ? progress?.message || "Translation failed"
798
+ : "");
799
+ return (_jsxs("div", { className: "w-[14rem] min-w-[14rem]", children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${job.status === "Completed"
566
800
  ? "bg-green-100 text-green-700"
567
801
  : job.status === "In Progress"
568
802
  ? ""
569
803
  : job.status === "Error"
570
804
  ? "bg-red-100 text-red-600"
571
- : "bg-[var(--color-gray-4)] text-[var(--color-gray-1)]"}`, style: job.status === "In Progress" ? { backgroundColor: '#f6eeff', color: '#9650fb' } : undefined, children: job.status }), job.status === "In Progress" && progress && Math.round(progress.progress || 0) === 0 && (_jsx("div", { className: "mt-1 text-xs text-[var(--color-gray-2)]", children: "Version created" })), progress && job.status === "In Progress" && (_jsxs("div", { className: "mt-1.5", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-xs text-[var(--color-gray-2)] truncate", "data-testid": "translation-progress-text", children: progress.message }), _jsxs("span", { className: "text-xs font-medium text-[var(--color-gray-1)] ml-1", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: { backgroundColor: '#9650fb' }, showValue: false, "data-testid": "translation-progress-bar" })] }))] }));
572
- }
805
+ : "bg-gray-4 text-(--color-gray-1)"}`, style: job.status === "In Progress"
806
+ ? {
807
+ backgroundColor: "#f6eeff",
808
+ color: "#9650fb",
809
+ }
810
+ : undefined, children: job.status }), job.status === "In Progress" &&
811
+ progress &&
812
+ Math.round(progress.progress || 0) === 0 && (_jsx("div", { className: "mt-1 text-xs text-gray-2", children: "Version created" })), progress && job.status === "In Progress" && (_jsxs("div", { className: "mt-1.5", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-xs text-gray-2 truncate", "data-testid": "translation-progress-text", children: progress.message }), _jsxs("span", { className: "text-xs font-medium text-(--color-gray-1) ml-1", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorStyle: {
813
+ backgroundColor: "#9650fb",
814
+ }, showValue: false, "data-testid": "translation-progress-bar" })] })), job.status === "Error" && (_jsx("div", { className: "mt-1.5", children: _jsx(Progress, { value: 100, className: "h-2", indicatorClassName: "bg-destructive", showValue: false, "data-testid": "translation-progress-bar" }) }))] }));
815
+ },
573
816
  },
574
817
  {
575
818
  header: "Source",
576
819
  className: "w-16", // Fixed width for source column
577
- body: (job) => (_jsx("div", { className: "text-sm text-[var(--color-gray-1)]", children: job.sourceLanguage || "—" }))
820
+ body: (job) => (_jsx("div", { className: "text-sm text-(--color-gray-1)", children: job.sourceLanguage || "—" })),
578
821
  },
579
822
  {
580
823
  header: "Message",
581
- className: "w-64", // Fixed width with proper wrapping for message column
582
- body: (job) => (_jsx("div", { className: "text-sm text-[var(--color-gray-2)] break-words whitespace-normal leading-relaxed", children: job.message || "—" }))
824
+ className: "w-full min-w-0",
825
+ body: (job) => {
826
+ const message = job.message ||
827
+ (job.status === "Error"
828
+ ? "Translation failed"
829
+ : "—");
830
+ return (_jsxs("div", { className: "flex items-start gap-1.5", children: [_jsx("div", { className: "min-w-0 w-full flex-1 text-sm text-gray-2 break-words [overflow-wrap:anywhere] whitespace-normal leading-relaxed line-clamp-3", title: message, children: message }), message !== "—" && (_jsx(CopyButton, { textToCopy: message, iconOnly: true, className: "mt-0.5 shrink-0 text-gray-2 hover:text-(--color-gray-1)" }))] }));
831
+ },
832
+ },
833
+ {
834
+ header: "Actions",
835
+ className: "w-24",
836
+ body: (job) => {
837
+ const jobRetryKey = getRetryJobKey(job);
838
+ const isRetryingJob = isRetryingAll || retryingJobKeys.has(jobRetryKey);
839
+ return (_jsxs("div", { className: "flex items-center justify-center gap-2", children: [job.status === "Error" && (_jsx(SimpleIconButton, { onClick: () => void handleRetryJobs([job], false), icon: _jsx(RefreshIcon, { className: `h-3.5 w-3.5 ${isRetryingJob ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Retry translation as new batch", disabled: isRetryingJob, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })), _jsx(SimpleIconButton, { onClick: () => openItemInEditor(job.itemId, job.targetLanguage), icon: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 }), label: `Open item in ${job.targetLanguage}`, disabled: isRetryingAll, className: "p-0! text-gray-2 hover:text-(--color-gray-1)" })] }));
840
+ },
583
841
  },
584
842
  {
585
843
  header: "Updated",
586
844
  className: "w-24", // Fixed width for updated column
587
845
  body: (job) => {
588
846
  const date = new Date(job.timestamp);
589
- return (_jsx("div", { className: "text-sm text-[var(--color-gray-2)] whitespace-nowrap", children: date.toLocaleTimeString() }));
590
- }
847
+ return (_jsx("div", { className: "text-sm text-gray-2 whitespace-nowrap", children: date.toLocaleTimeString() }));
848
+ },
591
849
  },
592
850
  ], items: jobs.sort((a, b) => {
593
851
  // Sort by status first (in-progress first, then errors, then completed)
@@ -607,6 +865,6 @@ export function BatchTranslationView({ batchId, onBack }) {
607
865
  return a.targetLanguage.localeCompare(b.targetLanguage);
608
866
  }
609
867
  return priorityA - priorityB;
610
- }) })] }, itemId));
868
+ }) }))] }, itemId));
611
869
  }) })) })] }));
612
870
  }