@parhelia/localization 0.1.12910 → 0.1.12912

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.
@@ -1,23 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState, useMemo } from "react";
2
+ import { useCallback, useEffect, useRef, useState, useMemo, useSyncExternalStore, } from "react";
3
3
  import React from "react";
4
- import { Button, Badge, Select, Input, UserPicker, Popover, PopoverContent, PopoverTrigger, ContentTree, fetchItemStubs, searchUsers as searchBackendUsers, useEditContext, getLanguages, SimpleIconButton, DeleteIcon, } from "@parhelia/core";
4
+ import { Button, Badge, Select, Input, UserPicker, Popover, PopoverContent, PopoverTrigger, Tooltip, TooltipContent, TooltipTrigger, ContentTree, PageHeader, fetchItemStubs, searchUsers as searchBackendUsers, useEditContext, getLanguages, SimpleIconButton, DeleteIcon, formatDateTime, } from "@parhelia/core";
5
5
  import { listBatches, getTranslationProviders, listBatchTranslationJobs, retryBatchTranslation, abortBatch, deleteBatch, } from "../services/translationService";
6
6
  import { TRANSLATION_BATCH_STARTED_EVENT, } from "../translationEvents";
7
7
  import { useDebouncedCallback } from "use-debounce";
8
- import { X, ChevronDown, Languages, Loader2, Calendar, CheckCircle2, User as UserIcon2, Cloud, Globe, FileText, FolderTree, ExternalLink, Check, AlertCircle, CircleStop, Hourglass, Copy, Sparkles, RotateCcw, } from "lucide-react";
8
+ import { X, ChevronDown, Languages, Loader2, Globe, FolderTree, ExternalLink, Check, AlertCircle, CircleStop, Hourglass, Copy, Sparkles, RotateCcw, Plus, } from "lucide-react";
9
9
  import { toast } from "sonner";
10
+ import { openLocalizeItemDialog } from "../LocalizeItemCommand";
10
11
  // Wrappers for lucide-react icons to work with React 19
11
12
  const XIcon = (props) => React.createElement(X, props);
12
13
  const ChevronDownIcon = (props) => React.createElement(ChevronDown, props);
13
14
  const LanguagesIcon = (props) => React.createElement(Languages, props);
14
15
  const LoaderIcon = (props) => React.createElement(Loader2, props);
15
- const CalendarIcon = (props) => React.createElement(Calendar, props);
16
- const StatusIcon = (props) => React.createElement(CheckCircle2, props);
17
- const UserFilterIcon = (props) => React.createElement(UserIcon2, props);
18
- const ProviderIcon = (props) => React.createElement(Cloud, props);
19
16
  const GlobeIcon = (props) => React.createElement(Globe, props);
20
- const ItemIcon = (props) => React.createElement(FileText, props);
21
17
  const FolderTreeIcon = (props) => React.createElement(FolderTree, props);
22
18
  const ExternalLinkIcon = (props) => React.createElement(ExternalLink, props);
23
19
  const CheckIcon = (props) => React.createElement(Check, props);
@@ -27,6 +23,70 @@ const QueuedIcon = (props) => React.createElement(Hourglass, props);
27
23
  const CopyIcon = (props) => React.createElement(Copy, props);
28
24
  const SparklesIcon = (props) => React.createElement(Sparkles, props);
29
25
  const RetryIcon = (props) => React.createElement(RotateCcw, props);
26
+ const PlusIcon = (props) => React.createElement(Plus, props);
27
+ function renderTargetLanguageIcon(language) {
28
+ if (language.icon) {
29
+ return React.createElement("img", {
30
+ src: language.icon,
31
+ className: "h-4 w-5 object-cover",
32
+ alt: "",
33
+ "aria-hidden": true,
34
+ });
35
+ }
36
+ return React.createElement(GlobeIcon, {
37
+ className: "h-3.5 w-3.5 text-neutral-grey-50",
38
+ strokeWidth: 1.5,
39
+ "aria-hidden": true,
40
+ });
41
+ }
42
+ const DEFAULT_FILTERS = {
43
+ dateRange: "last30days",
44
+ status: "all",
45
+ user: "all",
46
+ provider: "all",
47
+ targetLanguage: "all",
48
+ itemIdOrPath: "",
49
+ itemIncludeSubitems: false,
50
+ };
51
+ let filterSnapshot = {
52
+ filters: DEFAULT_FILTERS,
53
+ itemIdOrPathInput: "",
54
+ };
55
+ let defaultUserInitialized = false;
56
+ const filterListeners = new Set();
57
+ function emitFilterSnapshot() {
58
+ filterListeners.forEach((listener) => listener());
59
+ }
60
+ function subscribeFilterSnapshot(listener) {
61
+ filterListeners.add(listener);
62
+ return () => filterListeners.delete(listener);
63
+ }
64
+ function getFilterSnapshot() {
65
+ return filterSnapshot;
66
+ }
67
+ function setTranslationBatchFilters(updater) {
68
+ const nextFilters = typeof updater === "function" ? updater(filterSnapshot.filters) : updater;
69
+ filterSnapshot = { ...filterSnapshot, filters: nextFilters };
70
+ emitFilterSnapshot();
71
+ }
72
+ function setTranslationBatchItemInput(value) {
73
+ filterSnapshot = { ...filterSnapshot, itemIdOrPathInput: value };
74
+ emitFilterSnapshot();
75
+ }
76
+ function ensureDefaultTranslationBatchUser(currentUserName) {
77
+ if (defaultUserInitialized || currentUserName === "all")
78
+ return;
79
+ defaultUserInitialized = true;
80
+ if (filterSnapshot.filters.user !== "all")
81
+ return;
82
+ setTranslationBatchFilters((previous) => ({
83
+ ...previous,
84
+ user: currentUserName,
85
+ }));
86
+ }
87
+ function useTranslationBatchFilterSnapshot() {
88
+ return useSyncExternalStore(subscribeFilterSnapshot, getFilterSnapshot, getFilterSnapshot);
89
+ }
30
90
  const DATE_RANGE_OPTIONS = [
31
91
  { value: "lastHour", label: "Last Hour" },
32
92
  { value: "last24hours", label: "Last 24 Hours" },
@@ -78,6 +138,9 @@ function normalizeGuid(value) {
78
138
  .replace(/[{}()]/g, "")
79
139
  .toLowerCase();
80
140
  }
141
+ function normalizeLanguageCode(value) {
142
+ return (value ?? "").toString().trim().toLowerCase();
143
+ }
81
144
  function dateRangeToFromUtc(range) {
82
145
  const now = new Date();
83
146
  const cutoff = new Date(now);
@@ -114,14 +177,11 @@ function dateRangeToFromUtc(range) {
114
177
  export function TranslationBatches() {
115
178
  const editContext = useEditContext();
116
179
  const [batches, setBatches] = useState([]);
117
- const [providers, setProviders] = useState([]);
118
180
  const [sitecoreLanguages, setSitecoreLanguages] = useState([]);
119
181
  const [isLoading, setIsLoading] = useState(false);
120
182
  const [isLoadingMore, setIsLoadingMore] = useState(false);
121
183
  const [hasMore, setHasMore] = useState(true);
122
184
  const [currentOffset, setCurrentOffset] = useState(0);
123
- const [itemIdOrPathInput, setItemIdOrPathInput] = useState("");
124
- const [itemPickerOpen, setItemPickerOpen] = useState(false);
125
185
  const [expandedBatchId, setExpandedBatchId] = useState(null);
126
186
  const [expandedItems, setExpandedItems] = useState(new Set());
127
187
  const [batchJobs, setBatchJobs] = useState({});
@@ -138,21 +198,41 @@ export function TranslationBatches() {
138
198
  const requestIdRef = useRef(0);
139
199
  const currentUserName = editContext?.user?.name || "all";
140
200
  const isLimitedPreviewUser = editContext?.user?.isLimitedPreviewUser ?? false;
141
- const [filters, setFilters] = useState({
142
- dateRange: "last30days",
143
- status: "all",
144
- user: currentUserName,
145
- provider: "all",
146
- targetLanguage: "all",
147
- itemIdOrPath: "",
148
- itemIncludeSubitems: false,
149
- });
201
+ const isMobile = editContext?.isMobile ?? false;
202
+ const rootRef = useRef(null);
203
+ const [isCompactBatches, setIsCompactBatches] = useState(isMobile);
204
+ const { filters } = useTranslationBatchFilterSnapshot();
205
+ useEffect(() => {
206
+ const node = rootRef.current;
207
+ if (!node)
208
+ return;
209
+ const updateLayout = () => {
210
+ setIsCompactBatches(isMobile || node.clientWidth < 640);
211
+ };
212
+ updateLayout();
213
+ let observer;
214
+ if (typeof ResizeObserver !== "undefined") {
215
+ observer = new ResizeObserver(updateLayout);
216
+ observer.observe(node);
217
+ }
218
+ window.addEventListener("resize", updateLayout);
219
+ return () => {
220
+ observer?.disconnect();
221
+ window.removeEventListener("resize", updateLayout);
222
+ };
223
+ }, [isMobile]);
224
+ useEffect(() => {
225
+ ensureDefaultTranslationBatchUser(currentUserName);
226
+ }, [currentUserName]);
150
227
  // Enforce current user filter for limited preview users
151
228
  useEffect(() => {
152
229
  if (isLimitedPreviewUser &&
153
230
  currentUserName !== "all" &&
154
231
  filters.user !== currentUserName) {
155
- setFilters((prev) => ({ ...prev, user: currentUserName }));
232
+ setTranslationBatchFilters((prev) => ({
233
+ ...prev,
234
+ user: currentUserName,
235
+ }));
156
236
  }
157
237
  }, [isLimitedPreviewUser, currentUserName, filters.user]);
158
238
  const effectiveFilters = useMemo(() => {
@@ -220,29 +300,12 @@ export function TranslationBatches() {
220
300
  }
221
301
  }, []);
222
302
  const debouncedReload = useDebouncedCallback(() => loadRecentBatches(0, false, effectiveFilters), 800);
223
- const debouncedSetItemFilter = useDebouncedCallback((value) => setFilters((prev) => ({ ...prev, itemIdOrPath: value })), 400);
224
303
  const loadMore = () => {
225
304
  if (!isLoadingMore && hasMore) {
226
305
  loadRecentBatches(currentOffset, true, effectiveFilters);
227
306
  }
228
307
  };
229
- // Load providers
230
- useEffect(() => {
231
- const loadProviders = async () => {
232
- try {
233
- const res = await getTranslationProviders();
234
- const maybeProviders = res?.data ?? res;
235
- setProviders(Array.isArray(maybeProviders)
236
- ? maybeProviders
237
- : []);
238
- }
239
- catch (error) {
240
- console.error("Failed to load translation providers:", error);
241
- }
242
- };
243
- loadProviders();
244
- }, []);
245
- // Load Sitecore languages once for the Target Language dropdown
308
+ // Load Sitecore languages for language chips in expanded batches.
246
309
  useEffect(() => {
247
310
  const loadLanguages = async () => {
248
311
  try {
@@ -385,13 +448,6 @@ export function TranslationBatches() {
385
448
  // refresh that brings the UI to its final state.
386
449
  };
387
450
  }, [editContext]);
388
- const searchTranslationUsers = useCallback((query, limit) => searchBackendUsers(query, limit), []);
389
- // Languages available in the Target Language dropdown — sourced from Sitecore so
390
- // the list is stable regardless of the current filter selection.
391
- const languageOptions = useMemo(() => sitecoreLanguages
392
- .map((l) => l.languageCode || l.name)
393
- .filter((code) => !!code)
394
- .sort(), [sitecoreLanguages]);
395
451
  // Group by date. Filtering happens server-side, so we operate on `batches` directly.
396
452
  const groupedBatches = useMemo(() => {
397
453
  if (batches.length === 0)
@@ -549,7 +605,7 @@ export function TranslationBatches() {
549
605
  ]);
550
606
  const handleDeleteBatch = useCallback((batchId) => {
551
607
  editContext?.confirm({
552
- header: "Delete Translation History",
608
+ header: "Delete Translation Batch from History",
553
609
  message: "Delete this translation batch from history? This removes only translation job records; it does not delete Sitecore items or translated content.",
554
610
  acceptLabel: "Delete",
555
611
  showCancel: true,
@@ -652,67 +708,310 @@ export function TranslationBatches() {
652
708
  const openItemInEditor = useCallback(async (itemId, language) => {
653
709
  const normalized = (itemId ?? "").toString().toLowerCase();
654
710
  editContext?.setShowAgentsWorkspaceEditor(true);
655
- await editContext?.loadItem({ id: normalized, language, version: 0 });
711
+ await editContext?.loadItem(language ? { id: normalized, language, version: 0 } : normalized);
656
712
  }, [editContext]);
657
- const totalBatches = groupedBatches.reduce((sum, group) => sum + group.batches.length, 0);
658
- const isMobile = editContext?.isMobile ?? false;
713
+ return (_jsxs("div", { ref: rootRef, className: "flex h-full min-h-0 flex-col bg-neutral-grey-5", style: { padding: isCompactBatches ? 12 : 40 }, "data-testid": "translation-batches", children: [_jsx(PageHeader, { title: "Translation Batches", description: isMobile
714
+ ? undefined
715
+ : "Review translation history and manage batch progress", variant: "workspace", className: isCompactBatches ? "mb-4" : "mb-5" }), _jsx("main", { className: "min-h-0 min-w-0 flex-1 overflow-hidden rounded-[12px] bg-white", children: _jsx("div", { className: `h-full min-h-0 overflow-auto ${isCompactBatches ? "p-3" : "p-6"}`, 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: (() => {
716
+ const hasActiveFilters = filters.dateRange !== "last30days" ||
717
+ filters.status !== "all" ||
718
+ filters.user !== currentUserName ||
719
+ filters.provider !== "all" ||
720
+ filters.targetLanguage !== "all" ||
721
+ filters.itemIdOrPath.trim() !== "" ||
722
+ filters.itemIncludeSubitems;
723
+ return hasActiveFilters
724
+ ? "Try adjusting your filters or start a translation to see it appear here"
725
+ : "Start a translation to see it appear here";
726
+ })() })] }) })) : (_jsxs("div", { className: isCompactBatches ? "space-y-6" : "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-border-default divide-y overflow-hidden rounded-lg bg-white", children: dateGroup.batches.map((b) => {
727
+ const batchDate = new Date(b.timestamp);
728
+ const formattedBatchDate = formatDateTime(batchDate);
729
+ const info = b.info;
730
+ const totalJobs = info?.expectedJobs ?? 0;
731
+ const itemsCount = typeof info?.itemsCount === "number"
732
+ ? info.itemsCount
733
+ : undefined;
734
+ const languagesCsv = info?.languages;
735
+ const languages = languagesCsv
736
+ ? languagesCsv.split(",")
737
+ : [];
738
+ const completedJobs = info?.completedJobs ?? 0;
739
+ const errorJobs = info?.errorJobs ?? 0;
740
+ const anyError = errorJobs > 0;
741
+ const anyInProgress = isActiveBatchStatus(info?.status);
742
+ const anyAborted = info?.status === "Aborted";
743
+ const progressPct = totalJobs > 0
744
+ ? Math.min(100, Math.round((completedJobs / totalJobs) * 100))
745
+ : 0;
746
+ const progressTone = anyError
747
+ ? "error"
748
+ : anyAborted
749
+ ? "aborted"
750
+ : progressPct >= 100
751
+ ? "complete"
752
+ : "active";
753
+ const metaParts = [];
754
+ metaParts.push(_jsxs("span", { className: "text-neutral-grey-100", children: [totalJobs, " translation", totalJobs !== 1 ? "s" : ""] }, "trans"));
755
+ if (typeof itemsCount === "number") {
756
+ metaParts.push(_jsxs("span", { children: [itemsCount, " item", itemsCount !== 1 ? "s" : ""] }, "items"));
757
+ }
758
+ if (languages.length > 0) {
759
+ metaParts.push(_jsxs("span", { title: languages.join(", "), children: [languages.length, " language", languages.length !== 1 ? "s" : ""] }, "langs"));
760
+ }
761
+ if (info?.provider) {
762
+ metaParts.push(_jsx("span", { children: info.provider }, "prov"));
763
+ }
764
+ if (info?.totalCost != null) {
765
+ metaParts.push(_jsxs("span", { children: ["Cost:", " ", _jsx("span", { className: "font-medium text-neutral-grey-100", children: formatUsdCost(info.totalCost) })] }, "cost"));
766
+ }
767
+ const ownerPart = info?.initiatedByUser ? (_jsx("span", { title: info.initiatedByUser, children: userDisplayNames[info.initiatedByUser] ||
768
+ info.initiatedByUser }, "user")) : null;
769
+ const datePart = info?.lastUpdatedUtc ? (_jsx("span", { children: formatDateTime(new Date(info.lastUpdatedUtc)) }, "date")) : null;
770
+ const desktopMetaParts = [...metaParts];
771
+ if (!isCompactBatches) {
772
+ if (ownerPart)
773
+ desktopMetaParts.push(ownerPart);
774
+ if (datePart)
775
+ desktopMetaParts.push(datePart);
776
+ }
777
+ const mobileFooterMetaParts = [];
778
+ if (isCompactBatches) {
779
+ if (datePart)
780
+ mobileFooterMetaParts.push(datePart);
781
+ if (ownerPart)
782
+ mobileFooterMetaParts.push(ownerPart);
783
+ }
784
+ const batchDisplayName = (() => {
785
+ let parsedName;
786
+ if (info?.metadata) {
787
+ try {
788
+ const m = typeof info.metadata === "string"
789
+ ? JSON.parse(info.metadata)
790
+ : info.metadata;
791
+ const candidate = m?.name;
792
+ if (typeof candidate === "string" &&
793
+ candidate.trim()) {
794
+ parsedName = candidate.trim();
795
+ }
796
+ }
797
+ catch {
798
+ // Metadata isn't JSON - fall through to timestamp.
799
+ }
800
+ }
801
+ return (parsedName ??
802
+ `Translation Batch · ${formattedBatchDate}`);
803
+ })();
804
+ const isExpanded = expandedBatchId === b.id;
805
+ const jobs = batchJobs[b.id];
806
+ const isLoadingThis = loadingJobs.has(b.id);
807
+ const canRetry = anyError;
808
+ const canAbort = isActiveBatchStatus(info?.status);
809
+ const canDelete = isTerminalBatchStatus(info?.status);
810
+ const isRetryingBatch = retryingKeys.has(`batch:${b.id}`);
811
+ const isAborting = abortingBatchIds.has(b.id);
812
+ const isDeleting = deletingBatchIds.has(b.id);
813
+ const hasBatchActions = canRetry || canAbort || canDelete;
814
+ // Stable test-id status used by Playwright assertions.
815
+ // Matches the visual status dot/spinner logic above.
816
+ const testStatus = anyError
817
+ ? "error"
818
+ : anyInProgress
819
+ ? "in-progress"
820
+ : anyAborted
821
+ ? "aborted"
822
+ : "completed";
823
+ const batchActions = (_jsxs(_Fragment, { children: [canRetry && (_jsx(SimpleIconButton, { onClick: (event) => {
824
+ event.stopPropagation();
825
+ if (!canRetry)
826
+ return;
827
+ void handleRetryBatch(info);
828
+ }, icon: _jsx(RetryIcon, { className: `h-3.5 w-3.5 ${isRetryingBatch ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Retry failed translations", disabled: !canRetry ||
829
+ isRetryingBatch ||
830
+ isLoadingThis ||
831
+ isAborting ||
832
+ isDeleting })), canAbort && (_jsx(SimpleIconButton, { onClick: (event) => {
833
+ event.stopPropagation();
834
+ if (!canAbort)
835
+ return;
836
+ handleAbortBatch(b.id);
837
+ }, icon: _jsx(AbortIcon, { className: `h-3.5 w-3.5 ${isAborting ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Abort translation batch", disabled: !canAbort || isAborting || isDeleting })), canDelete && (_jsx(SimpleIconButton, { onClick: (event) => {
838
+ event.stopPropagation();
839
+ if (!canDelete)
840
+ return;
841
+ handleDeleteBatch(b.id);
842
+ }, icon: _jsx(DeleteIcon, { size: "md" }), label: "Delete translation batch from history", disabled: !canDelete || isDeleting || isAborting }))] }));
843
+ return (_jsxs("div", { "data-testid": `batch-row-${b.id}`, "data-batch-status": testStatus, children: [_jsx("div", { role: "button", tabIndex: 0, className: `group w-full cursor-pointer py-3 text-left transition-colors hover:bg-neutral-grey-5 ${isCompactBatches ? "px-2.5" : "px-4"}`, onClick: () => toggleBatch(b.id), onKeyDown: (e) => {
844
+ if (e.key === "Enter" || e.key === " ") {
845
+ e.preventDefault();
846
+ toggleBatch(b.id);
847
+ }
848
+ }, children: _jsxs("div", { className: `flex items-start ${isCompactBatches ? "gap-2" : "gap-4"}`, children: [_jsx("div", { className: `shrink-0 text-neutral-grey-100 transition-transform ${isCompactBatches ? "mt-0.5" : "mt-3"} ${isExpanded ? "rotate-180" : ""}`, children: _jsx(ChevronDownIcon, { className: "h-4 w-4" }) }), !isCompactBatches && totalJobs > 0 && (_jsx(BatchProgressIndicator, { value: progressPct, completedJobs: completedJobs, totalJobs: totalJobs, errorJobs: errorJobs, tone: progressTone, testId: `batch-progress-${b.id}` })), _jsxs("div", { className: `flex min-w-0 flex-1 ${isCompactBatches
849
+ ? "flex-col gap-2"
850
+ : "items-center justify-between gap-3"}`, children: [_jsxs("div", { className: "grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)]", children: [_jsx("span", { className: `min-w-0 text-[14px] font-medium text-neutral-grey-100 ${isCompactBatches
851
+ ? "whitespace-normal break-words leading-5"
852
+ : "truncate"}`, style: isCompactBatches
853
+ ? { overflowWrap: "anywhere" }
854
+ : undefined, title: formattedBatchDate, children: batchDisplayName }), _jsx("div", { className: "mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-neutral-grey-50", children: desktopMetaParts.map((part, i) => (_jsxs(React.Fragment, { children: [i > 0 && (_jsx("span", { className: "text-neutral-grey-15", children: "\u00B7" })), part] }, i))) }), mobileFooterMetaParts.length > 0 && (_jsx("div", { className: "mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-neutral-grey-50", children: mobileFooterMetaParts.map((part, i) => (_jsxs(React.Fragment, { children: [i > 0 && (_jsx("span", { className: "text-neutral-grey-15", children: "\u00B7" })), part] }, `footer-${i}`))) }))] }), isCompactBatches && (_jsxs("div", { className: "mt-2 flex min-w-0 items-center gap-2", children: [totalJobs > 0 && (_jsx("div", { className: "min-w-0 flex-1", children: _jsx(BatchProgressIndicator, { value: progressPct, completedJobs: completedJobs, totalJobs: totalJobs, errorJobs: errorJobs, tone: progressTone, testId: `batch-progress-${b.id}`, variant: "bar" }) })), hasBatchActions && (_jsx("div", { className: "flex shrink-0 items-center gap-1.5", children: batchActions }))] })), !isCompactBatches && hasBatchActions && (_jsx("div", { className: "flex shrink-0 items-center gap-2 self-center", children: batchActions }))] })] }) }), isExpanded && (_jsxs("div", { className: "border-t border-border-default/50", children: [_jsx(CustomPromptPanel, { prompts: parseCustomPrompts(info?.metadata) }), _jsx(BatchItemList, { batchId: b.id, batchProvider: info?.provider, jobs: jobs, isLoading: isLoadingThis, expandedItems: expandedItems, languages: sitecoreLanguages, currentLanguage: editContext?.item?.language, itemFilter: filters.itemIdOrPath.trim(), itemIncludeSubitems: filters.itemIncludeSubitems, statusFilter: filters.status, batchIsTerminal: isTerminalBatchStatus(info?.status), batchStartedAtUtc: info?.startedAtUtc ?? info?.createdAtUtc, retryingKeys: retryingKeys, isMobile: isMobile, onToggleItem: toggleItem, onOpenItem: openItemInEditor, onRetryJobs: handleRetryJobs })] }))] }, b.id));
855
+ }) })] }, 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"] })) }) }))] })) }) })] }));
856
+ }
857
+ export const TRANSLATION_BATCH_FILTERS_SIDEBAR_ID = "translation-batch-filters";
858
+ export function TranslationBatchFiltersSidebar() {
859
+ const editContext = useEditContext();
860
+ const { filters, itemIdOrPathInput } = useTranslationBatchFilterSnapshot();
861
+ const [providers, setProviders] = useState([]);
862
+ const [sitecoreLanguages, setSitecoreLanguages] = useState([]);
863
+ const [itemPickerOpen, setItemPickerOpen] = useState(false);
864
+ const currentUserName = editContext?.user?.name || "all";
865
+ const isLimitedPreviewUser = editContext?.user?.isLimitedPreviewUser ?? false;
866
+ useEffect(() => {
867
+ ensureDefaultTranslationBatchUser(currentUserName);
868
+ }, [currentUserName]);
869
+ useEffect(() => {
870
+ if (isLimitedPreviewUser &&
871
+ currentUserName !== "all" &&
872
+ filters.user !== currentUserName) {
873
+ setTranslationBatchFilters((prev) => ({
874
+ ...prev,
875
+ user: currentUserName,
876
+ }));
877
+ }
878
+ }, [isLimitedPreviewUser, currentUserName, filters.user]);
879
+ const searchTranslationUsers = useCallback((query, limit) => searchBackendUsers(query, limit), []);
880
+ const debouncedSetItemFilter = useDebouncedCallback((value) => setTranslationBatchFilters((prev) => ({
881
+ ...prev,
882
+ itemIdOrPath: value,
883
+ })), 400);
884
+ useEffect(() => {
885
+ const loadProviders = async () => {
886
+ try {
887
+ const res = await getTranslationProviders();
888
+ const maybeProviders = res?.data ?? res;
889
+ setProviders(Array.isArray(maybeProviders)
890
+ ? maybeProviders
891
+ : []);
892
+ }
893
+ catch (error) {
894
+ console.error("Failed to load translation providers:", error);
895
+ }
896
+ };
897
+ loadProviders();
898
+ }, []);
899
+ useEffect(() => {
900
+ const loadLanguages = async () => {
901
+ try {
902
+ const res = await getLanguages();
903
+ const langs = (res?.data ?? res ?? []);
904
+ setSitecoreLanguages(Array.isArray(langs) ? langs : []);
905
+ }
906
+ catch (error) {
907
+ console.error("Failed to load Sitecore languages:", error);
908
+ }
909
+ };
910
+ loadLanguages();
911
+ }, []);
912
+ const languageOptions = useMemo(() => sitecoreLanguages
913
+ .map((language) => {
914
+ const code = language.languageCode || language.name;
915
+ return code ? { ...language, languageCode: code } : null;
916
+ })
917
+ .filter((language) => language !== null)
918
+ .sort((a, b) => a.languageCode.localeCompare(b.languageCode)), [sitecoreLanguages]);
919
+ const currentItem = editContext?.item;
920
+ const multiItemEnabled = editContext?.configuration?.localization?.multiItem !== false;
921
+ const canStartTranslation = !!editContext && (multiItemEnabled || !!currentItem);
922
+ const handleStartTranslation = useCallback(() => {
923
+ if (!editContext)
924
+ return;
925
+ void openLocalizeItemDialog({
926
+ editContext,
927
+ items: currentItem ? [currentItem] : [],
928
+ });
929
+ }, [currentItem, editContext]);
659
930
  const hasActiveFilters = filters.dateRange !== "last30days" ||
660
931
  filters.status !== "all" ||
661
932
  filters.user !== currentUserName ||
662
933
  filters.provider !== "all" ||
663
934
  filters.targetLanguage !== "all" ||
664
- filters.itemIdOrPath.trim() !== "";
665
- const filterLabelClass = "flex items-center gap-1.5 text-[10px] font-medium tracking-[0.06em] text-neutral-grey-50";
666
- 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) => ({
935
+ filters.itemIdOrPath.trim() !== "" ||
936
+ filters.itemIncludeSubitems;
937
+ const resetFilters = useCallback(() => {
938
+ debouncedSetItemFilter.cancel();
939
+ setTranslationBatchItemInput("");
940
+ setTranslationBatchFilters({
941
+ ...DEFAULT_FILTERS,
942
+ user: currentUserName,
943
+ });
944
+ }, [currentUserName, debouncedSetItemFilter]);
945
+ const filterLabelClass = "text-[12px] font-medium text-neutral-grey-50";
946
+ const filterFieldClass = "flex flex-col gap-1";
947
+ return (_jsxs("div", { className: "flex h-full min-h-0 flex-col bg-white", "data-testid": "translation-filters-sidebar", children: [_jsx("div", { className: "border-border-default flex shrink-0 flex-col gap-2 border-b px-3 py-3", children: _jsxs(Button, { type: "button", variant: "outline", size: "lg", title: canStartTranslation
948
+ ? "Start new translation"
949
+ : "Open an item to translate", "data-testid": "translation-sidebar-start-new-translation-button", disabled: !canStartTranslation, onClick: handleStartTranslation, className: "border-border-default hover:border-neutral-grey-100 hover:bg-neutral-grey-5 h-9 w-full justify-center gap-3 rounded-md bg-white px-3 text-sm font-medium shadow-none", children: [_jsx("span", { className: "truncate", children: "Start new translation" }), _jsx(PlusIcon, { className: "size-4", strokeWidth: 1.5 })] }) }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto px-3 py-4", children: _jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "Date Range" }), _jsx(Select, { className: "w-full bg-white", options: DATE_RANGE_OPTIONS, value: filters.dateRange, onValueChange: (value) => setTranslationBatchFilters((prev) => ({
667
950
  ...prev,
668
951
  dateRange: value,
669
- })), 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) => ({
952
+ })), placeholder: "Select date range" })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "Status" }), _jsx(Select, { className: "w-full bg-white", options: STATUS_OPTIONS, value: filters.status, onValueChange: (value) => setTranslationBatchFilters((prev) => ({
670
953
  ...prev,
671
954
  status: value,
672
- })), 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
955
+ })), placeholder: "Select status" })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "User" }), _jsx(UserPicker, { value: isLimitedPreviewUser
673
956
  ? currentUserName
674
957
  : filters.user === "all"
675
958
  ? null
676
959
  : filters.user, displayValue: isLimitedPreviewUser || filters.user === currentUserName
677
960
  ? `${editContext?.user?.fullName?.trim() || currentUserName} (You)`
678
- : undefined, onChange: (user) => setFilters((prev) => ({
961
+ : undefined, displayAvatarName: isLimitedPreviewUser || filters.user === currentUserName
962
+ ? editContext?.user?.fullName?.trim() || currentUserName
963
+ : undefined, displayAvatarUrl: isLimitedPreviewUser || filters.user === currentUserName
964
+ ? editContext?.user?.avatarUrl
965
+ : undefined, onChange: (user) => setTranslationBatchFilters((prev) => ({
679
966
  ...prev,
680
967
  user: user?.userName ?? "all",
681
- })), 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: [
968
+ })), disabled: isLimitedPreviewUser, placeholder: "All Users", buttonClassName: "h-auto min-h-[34px] w-full justify-start rounded-md border bg-white py-2 text-xs font-medium", allowClear: true, clearLabel: "All Users", showIcon: isLimitedPreviewUser || filters.user !== "all", searchUsers: searchTranslationUsers })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "Provider" }), _jsx(Select, { className: "w-full bg-white", options: [
682
969
  { value: "all", label: "All Providers" },
683
970
  ...providers.map((p) => ({
684
971
  value: p.name,
685
972
  label: p.displayName || p.name,
686
973
  })),
687
- ], 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: [
688
- { value: "all", label: "All Languages" },
689
- ...languageOptions.map((lang) => ({
690
- value: lang,
691
- label: lang,
974
+ ], value: filters.provider, onValueChange: (value) => setTranslationBatchFilters((prev) => ({
975
+ ...prev,
976
+ provider: value,
977
+ })), placeholder: "Select provider" })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "Target Language" }), _jsx(Select, { className: "w-full bg-white", options: [
978
+ {
979
+ value: "all",
980
+ label: "All Languages",
981
+ },
982
+ ...languageOptions.map((language) => ({
983
+ value: language.languageCode,
984
+ label: language.languageCode,
985
+ icon: renderTargetLanguageIcon(language),
692
986
  })),
693
- ], 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) => ({
987
+ ], value: filters.targetLanguage, onValueChange: (value) => setTranslationBatchFilters((prev) => ({
988
+ ...prev,
989
+ targetLanguage: value,
990
+ })), placeholder: "Select language" })] }), _jsxs("div", { className: filterFieldClass, children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("label", { className: filterLabelClass, children: "Item ID / Path" }), _jsxs("label", { className: "flex cursor-pointer items-center gap-1 text-[10px] text-neutral-grey-50 select-none", children: [_jsx("input", { type: "checkbox", checked: filters.itemIncludeSubitems, onChange: (e) => setTranslationBatchFilters((prev) => ({
694
991
  ...prev,
695
992
  itemIncludeSubitems: e.target.checked,
696
- })), className: "h-3 w-3 accent-highlight-100 cursor-pointer" }), "Include subitems"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Input, { value: itemIdOrPathInput, onChange: (e) => {
993
+ })), className: "h-3 w-3 cursor-pointer accent-highlight-100" }), "Include subitems"] })] }), _jsxs("div", { className: "relative", children: [_jsx(Input, { value: itemIdOrPathInput, onChange: (e) => {
697
994
  const next = e.target.value;
698
- setItemIdOrPathInput(next);
995
+ setTranslationBatchItemInput(next);
699
996
  debouncedSetItemFilter(next);
700
- }, 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: () => {
701
- setItemIdOrPathInput("");
997
+ }, placeholder: "GUID or /sitecore/content/...", className: "h-auto min-h-[34px] w-full bg-white py-2 pr-14 text-xs" }), _jsxs("div", { className: "absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center gap-1", children: [itemIdOrPathInput && (_jsx("button", { type: "button", onClick: () => {
998
+ setTranslationBatchItemInput("");
702
999
  debouncedSetItemFilter.cancel();
703
- setFilters((prev) => ({
1000
+ setTranslationBatchFilters((prev) => ({
704
1001
  ...prev,
705
1002
  itemIdOrPath: "",
706
1003
  itemIncludeSubitems: false,
707
1004
  }));
708
- }, 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) => {
1005
+ }, className: "cursor-pointer text-neutral-grey-50 hover:text-neutral-grey-100", "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: "cursor-pointer text-neutral-grey-50 hover:text-neutral-grey-100", "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"], initialExpandedKeys: [
1006
+ "0de95ae4-41ab-4d01-9eb0-67441b7c2450",
1007
+ ], onSelectionChange: (items) => {
709
1008
  const picked = items?.[0];
710
1009
  if (!picked)
711
1010
  return;
712
1011
  const applyValue = (value) => {
713
- setItemIdOrPathInput(value);
1012
+ setTranslationBatchItemInput(value);
714
1013
  debouncedSetItemFilter.cancel();
715
- setFilters((prev) => ({
1014
+ setTranslationBatchFilters((prev) => ({
716
1015
  ...prev,
717
1016
  itemIdOrPath: value,
718
1017
  }));
@@ -722,134 +1021,19 @@ export function TranslationBatches() {
722
1021
  applyValue(picked.path);
723
1022
  return;
724
1023
  }
725
- // Fall back: resolve the item's path via a stub fetch
726
- // so the input shows a human-readable path instead of a GUID.
727
1024
  fetchItemStubs([
728
- { id: picked.id, language: "en", version: 1 },
1025
+ {
1026
+ id: picked.id,
1027
+ language: "en",
1028
+ version: 1,
1029
+ },
729
1030
  ])
730
1031
  .then((stubs) => {
731
1032
  const stubPath = stubs?.[0]?.path;
732
1033
  applyValue(stubPath || picked.id);
733
1034
  })
734
1035
  .catch(() => applyValue(picked.id));
735
- } }) })] })] })] })] })] })] }) }), _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: (() => {
736
- const hasActiveFilters = filters.dateRange !== "last30days" ||
737
- filters.status !== "all" ||
738
- filters.user !== currentUserName ||
739
- filters.provider !== "all" ||
740
- filters.targetLanguage !== "all" ||
741
- filters.itemIdOrPath.trim() !== "";
742
- return hasActiveFilters
743
- ? "Try adjusting your filters or start a translation to see it appear here"
744
- : "Start a translation to see it appear here";
745
- })() })] }) })) : (_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) => {
746
- const batchDate = new Date(b.timestamp);
747
- const now = new Date();
748
- const isToday = batchDate.toDateString() === now.toDateString();
749
- const timeDisplay = isToday
750
- ? batchDate.toLocaleTimeString()
751
- : batchDate.toLocaleDateString() +
752
- " " +
753
- batchDate.toLocaleTimeString();
754
- const info = b.info;
755
- const totalJobs = info?.expectedJobs ?? 0;
756
- const itemsCount = typeof info?.itemsCount === "number"
757
- ? info.itemsCount
758
- : undefined;
759
- const languagesCsv = info?.languages;
760
- const languages = languagesCsv
761
- ? languagesCsv.split(",")
762
- : [];
763
- const completedJobs = info?.completedJobs ?? 0;
764
- const errorJobs = info?.errorJobs ?? 0;
765
- const anyError = errorJobs > 0;
766
- const anyInProgress = isActiveBatchStatus(info?.status);
767
- const anyAborted = info?.status === "Aborted";
768
- const progressPct = totalJobs > 0
769
- ? Math.min(100, Math.round((completedJobs / totalJobs) * 100))
770
- : 0;
771
- const showProgress = totalJobs > 0 && (anyInProgress || anyError);
772
- const statusDotClass = anyError
773
- ? "bg-feedback-red"
774
- : anyInProgress
775
- ? "bg-highlight-100"
776
- : anyAborted
777
- ? "bg-feedback-orange"
778
- : "bg-feedback-green/70";
779
- const metaParts = [];
780
- metaParts.push(_jsxs("span", { className: "text-neutral-grey-100", children: [totalJobs, " translation", totalJobs !== 1 ? "s" : ""] }, "trans"));
781
- if (typeof itemsCount === "number") {
782
- metaParts.push(_jsxs("span", { children: [itemsCount, " item", itemsCount !== 1 ? "s" : ""] }, "items"));
783
- }
784
- if (languages.length > 0) {
785
- metaParts.push(_jsxs("span", { title: languages.join(", "), children: [languages.length, " language", languages.length !== 1 ? "s" : ""] }, "langs"));
786
- }
787
- if (info?.provider) {
788
- metaParts.push(_jsx("span", { children: info.provider }, "prov"));
789
- }
790
- if (info?.totalCost != null) {
791
- metaParts.push(_jsxs("span", { children: ["Cost:", " ", _jsx("span", { className: "font-medium text-neutral-grey-100", children: formatUsdCost(info.totalCost) })] }, "cost"));
792
- }
793
- if (info?.initiatedByUser) {
794
- const resolved = userDisplayNames[info.initiatedByUser];
795
- metaParts.push(_jsx("span", { title: info.initiatedByUser, children: resolved || info.initiatedByUser }, "user"));
796
- }
797
- const isExpanded = expandedBatchId === b.id;
798
- const jobs = batchJobs[b.id];
799
- const isLoadingThis = loadingJobs.has(b.id);
800
- const canRetry = anyError;
801
- const canAbort = isActiveBatchStatus(info?.status);
802
- const canDelete = isTerminalBatchStatus(info?.status);
803
- const isRetryingBatch = retryingKeys.has(`batch:${b.id}`);
804
- const isAborting = abortingBatchIds.has(b.id);
805
- const isDeleting = deletingBatchIds.has(b.id);
806
- // Stable test-id status used by Playwright assertions.
807
- // Matches the visual status dot/spinner logic above.
808
- const testStatus = anyError
809
- ? "error"
810
- : anyInProgress
811
- ? "in-progress"
812
- : anyAborted
813
- ? "aborted"
814
- : "completed";
815
- 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) => {
816
- if (e.key === "Enter" || e.key === " ") {
817
- e.preventDefault();
818
- toggleBatch(b.id);
819
- }
820
- }, 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: (() => {
821
- let parsedName;
822
- if (info?.metadata) {
823
- try {
824
- const m = typeof info.metadata === "string"
825
- ? JSON.parse(info.metadata)
826
- : info.metadata;
827
- const candidate = m?.name;
828
- if (typeof candidate === "string" &&
829
- candidate.trim()) {
830
- parsedName = candidate.trim();
831
- }
832
- }
833
- catch {
834
- // Metadata isn't JSON — fall through to timestamp.
835
- }
836
- }
837
- return (parsedName ??
838
- `Translation Batch · ${timeDisplay}`);
839
- })() }), 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) => {
840
- event.stopPropagation();
841
- void handleRetryBatch(info);
842
- }, icon: _jsx(RetryIcon, { className: `h-3.5 w-3.5 ${isRetryingBatch ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Retry failed translations", disabled: isRetryingBatch ||
843
- isLoadingThis ||
844
- isAborting ||
845
- isDeleting, className: "p-0! text-feedback-red hover:text-feedback-red" })), canAbort && (_jsx(SimpleIconButton, { onClick: (event) => {
846
- event.stopPropagation();
847
- handleAbortBatch(b.id);
848
- }, 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) => {
849
- event.stopPropagation();
850
- handleDeleteBatch(b.id);
851
- }, 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));
852
- }) })] }, 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"] })) }) }))] })) })] }));
1036
+ } }) })] })] })] })] })] }), _jsx("div", { className: "flex justify-start pt-1", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: resetFilters, disabled: !hasActiveFilters, children: [_jsx(XIcon, { className: "h-3.5 w-3.5" }), "Reset Filters"] }) })] }) })] }));
853
1037
  }
854
1038
  function isLikelyGuid(value) {
855
1039
  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());
@@ -932,6 +1116,45 @@ function formatUsdCost(value) {
932
1116
  maximumFractionDigits: value > 0 && value < 0.01 ? 6 : 2,
933
1117
  }).format(value);
934
1118
  }
1119
+ function BatchProgressIndicator({ value, completedJobs, totalJobs, errorJobs, tone, testId, variant = "circle", }) {
1120
+ const percent = Math.max(0, Math.min(100, Math.round(value)));
1121
+ const radius = 17;
1122
+ const circumference = 2 * Math.PI * radius;
1123
+ const dashOffset = circumference - (percent / 100) * circumference;
1124
+ const hasErrors = errorJobs > 0;
1125
+ const toneClass = tone === "complete"
1126
+ ? "text-feedback-green"
1127
+ : tone === "error"
1128
+ ? "text-feedback-red"
1129
+ : tone === "aborted"
1130
+ ? "text-feedback-orange"
1131
+ : "text-neutral-grey-50";
1132
+ const toneColor = tone === "complete"
1133
+ ? "var(--color-feedback-green)"
1134
+ : tone === "error"
1135
+ ? "var(--color-feedback-red)"
1136
+ : tone === "aborted"
1137
+ ? "var(--color-feedback-orange)"
1138
+ : "var(--color-neutral-grey-50)";
1139
+ const trackColor = tone === "complete"
1140
+ ? "var(--color-feedback-green-light)"
1141
+ : tone === "error"
1142
+ ? "var(--color-feedback-red-light)"
1143
+ : tone === "aborted"
1144
+ ? "var(--color-feedback-orange-light)"
1145
+ : "var(--color-neutral-grey-15)";
1146
+ const label = `${percent}% complete (${completedJobs}/${totalJobs} translations${hasErrors ? `, ${errorJobs} failed` : ""})`;
1147
+ if (variant === "bar") {
1148
+ return (_jsxs(Tooltip, { delayDuration: 250, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs("div", { className: "flex min-w-0 items-center gap-2", "aria-label": label, "data-testid": testId, children: [_jsx("div", { className: "h-1.5 min-w-0 flex-1 overflow-hidden rounded-full", style: { backgroundColor: trackColor }, children: _jsx("div", { className: "h-full rounded-full transition-[width]", style: {
1149
+ width: `${percent}%`,
1150
+ backgroundColor: toneColor,
1151
+ } }) }), _jsxs("span", { className: `shrink-0 text-[11px] leading-none font-medium tabular-nums ${toneClass}`, children: [percent, "%"] })] }) }), _jsx(TooltipContent, { side: "top", sideOffset: 6, children: label })] }));
1152
+ }
1153
+ return (_jsxs(Tooltip, { delayDuration: 250, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs("div", { className: `relative h-10 w-10 shrink-0 rounded-full ${tone === "complete" ? "bg-feedback-green-light" : ""}`, "aria-label": label, "data-testid": testId, children: [_jsxs("svg", { className: "h-10 w-10 -rotate-90", viewBox: "0 0 40 40", children: [_jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", stroke: trackColor, strokeWidth: "2" }), _jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", stroke: toneColor, strokeLinecap: "round", strokeWidth: "2", style: {
1154
+ strokeDasharray: circumference,
1155
+ strokeDashoffset: dashOffset,
1156
+ } })] }), _jsxs("span", { className: `absolute inset-0 flex items-center justify-center text-[10px] leading-none font-medium tabular-nums ${toneClass}`, children: [percent, "%"] })] }) }), _jsx(TooltipContent, { side: "top", sideOffset: 6, children: label })] }));
1157
+ }
935
1158
  function itemStatusDot(jobs) {
936
1159
  const hasError = jobs.some((j) => j.status === "Error");
937
1160
  const hasInProgress = jobs.some((j) => j.status === "In Progress");
@@ -947,6 +1170,30 @@ function itemStatusDot(jobs) {
947
1170
  return { cls: "bg-feedback-orange", label: "Aborted" };
948
1171
  return { cls: "bg-feedback-green/70", label: "Completed" };
949
1172
  }
1173
+ function InlineLanguageJobChips({ batchId, batchProvider, jobs, languageMap, isExpanded, batchStartedAtUtc, retryingKeys, singleLine = false, onToggle, onOpenItem, onRetryJobs, }) {
1174
+ if (jobs.length === 0)
1175
+ return null;
1176
+ const handleCellClick = (event) => {
1177
+ event.stopPropagation();
1178
+ onToggle();
1179
+ };
1180
+ const handleCellKeyDown = (event) => {
1181
+ if (event.key === "Enter" || event.key === " ") {
1182
+ event.preventDefault();
1183
+ event.stopPropagation();
1184
+ onToggle();
1185
+ }
1186
+ };
1187
+ return (_jsx("div", { role: "button", tabIndex: 0, className: `flex min-w-0 cursor-pointer items-start justify-start gap-1.5 ${singleLine
1188
+ ? "flex-nowrap"
1189
+ : isExpanded
1190
+ ? "flex-wrap"
1191
+ : "max-h-7 flex-wrap overflow-hidden"}`, "aria-label": `${jobs.length} target language${jobs.length !== 1 ? "s" : ""}`, "aria-expanded": isExpanded, onClick: handleCellClick, onKeyDown: handleCellKeyDown, children: jobs.map((job, idx) => {
1192
+ const lang = languageMap.get((job.targetLanguage || "").toLowerCase());
1193
+ const retryKey = `job:${job.id ?? `${job.itemId}:${job.sourceLanguage}:${job.targetLanguage}`}`;
1194
+ return (_jsx("span", { className: "shrink-0", onClick: (event) => event.stopPropagation(), children: _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}`));
1195
+ }) }));
1196
+ }
950
1197
  function CustomPromptPanel({ prompts }) {
951
1198
  const [openProvider, setOpenProvider] = React.useState(null);
952
1199
  const [copiedProvider, setCopiedProvider] = React.useState(null);
@@ -971,7 +1218,7 @@ function CustomPromptPanel({ prompts }) {
971
1218
  toast.error("Failed to copy prompt to clipboard");
972
1219
  }
973
1220
  };
974
- return (_jsx("div", { className: "bg-neutral-grey-5/60 border-t border-border-default/50", children: prompts.map((p) => {
1221
+ return (_jsx("div", { className: "border-b border-border-default/50", children: prompts.map((p) => {
975
1222
  const isOpen = openProvider === p.provider;
976
1223
  const justCopied = copiedProvider === p.provider;
977
1224
  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) => {
@@ -982,10 +1229,10 @@ function CustomPromptPanel({ prompts }) {
982
1229
  }, 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) => {
983
1230
  event.stopPropagation();
984
1231
  void handleCopy(p.provider, p.prompt);
985
- }, 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));
1232
+ }, 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" })), _jsx(ChevronDownIcon, { className: `h-3.5 w-3.5 text-neutral-grey-100 transition-transform ${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));
986
1233
  }) }));
987
1234
  }
988
- function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems, languages, itemFilter, itemIncludeSubitems, statusFilter, batchIsTerminal, batchStartedAtUtc, retryingKeys, onToggleItem, onOpenItem, onRetryJobs, }) {
1235
+ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems, languages, currentLanguage, itemFilter, itemIncludeSubitems, statusFilter, batchIsTerminal, batchStartedAtUtc, retryingKeys, isMobile = false, onToggleItem, onOpenItem, onRetryJobs, }) {
989
1236
  const languageMap = React.useMemo(() => {
990
1237
  const map = new Map();
991
1238
  for (const l of languages) {
@@ -1066,6 +1313,57 @@ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems,
1066
1313
  return [];
1067
1314
  return filteredJobs.filter((j) => matchingItemIds.has((j.itemId || "").toString().toLowerCase()));
1068
1315
  }, [filteredJobs, statusFilter]);
1316
+ const [stackLanguageChips, setStackLanguageChips] = React.useState(isMobile);
1317
+ const itemTableContainerRef = React.useRef(null);
1318
+ const itemTableRef = React.useRef(null);
1319
+ const normalTableMinWidthRef = React.useRef(null);
1320
+ React.useEffect(() => {
1321
+ const container = itemTableContainerRef.current;
1322
+ const table = itemTableRef.current;
1323
+ if (!container || !table)
1324
+ return;
1325
+ const updateLayout = () => {
1326
+ const availableWidth = container.clientWidth;
1327
+ if (!availableWidth)
1328
+ return;
1329
+ if (isMobile || availableWidth < 640) {
1330
+ setStackLanguageChips(true);
1331
+ return;
1332
+ }
1333
+ if (stackLanguageChips) {
1334
+ const normalMinWidth = normalTableMinWidthRef.current;
1335
+ if (normalMinWidth && availableWidth >= normalMinWidth) {
1336
+ setStackLanguageChips(false);
1337
+ }
1338
+ return;
1339
+ }
1340
+ const requiredWidth = table.scrollWidth;
1341
+ normalTableMinWidthRef.current = requiredWidth;
1342
+ if (requiredWidth > availableWidth + 1) {
1343
+ setStackLanguageChips(true);
1344
+ }
1345
+ };
1346
+ updateLayout();
1347
+ const animationFrame = window.requestAnimationFrame(updateLayout);
1348
+ const timeout = window.setTimeout(updateLayout, 150);
1349
+ let observer;
1350
+ if (typeof ResizeObserver !== "undefined") {
1351
+ observer = new ResizeObserver(updateLayout);
1352
+ observer.observe(container);
1353
+ observer.observe(table);
1354
+ }
1355
+ window.addEventListener("resize", updateLayout);
1356
+ return () => {
1357
+ window.cancelAnimationFrame(animationFrame);
1358
+ window.clearTimeout(timeout);
1359
+ observer?.disconnect();
1360
+ window.removeEventListener("resize", updateLayout);
1361
+ };
1362
+ }, [
1363
+ isMobile,
1364
+ stackLanguageChips,
1365
+ statusFilteredJobs,
1366
+ ]);
1069
1367
  if (isLoading && !jobs) {
1070
1368
  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"] }));
1071
1369
  }
@@ -1083,37 +1381,46 @@ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems,
1083
1381
  byItem.set(key, []);
1084
1382
  byItem.get(key).push(job);
1085
1383
  }
1086
- 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]) => {
1087
- const isItemExpanded = expandedItems.has(itemId);
1088
- const itemAnyInProgress = itemJobs.some((j) => j.status === "In Progress");
1089
- const itemAnyPending = itemJobs.some((j) => j.status === "Pending");
1090
- const itemIsQueued = !itemAnyInProgress && itemAnyPending;
1091
- const status = itemStatusDot(itemJobs);
1092
- const firstJob = itemJobs[0];
1093
- const itemPath = firstJob?.itemPath ?? null;
1094
- const serverName = firstJob?.itemName ?? null;
1095
- const metadataName = parseItemName(firstJob?.metadata, "");
1096
- const displayName = metadataName || serverName || itemId;
1097
- const langs = itemJobs.map((j) => j.targetLanguage).filter(Boolean);
1098
- const uniqueLangs = Array.from(new Set(langs));
1099
- const titleAttr = `${itemPath ?? ""}\n${itemId}`.trim();
1100
- const itemErrorJobs = itemJobs.filter(isRetriableJob);
1101
- const itemRetryKey = `item:${batchId}:${itemId}`;
1102
- const isRetryingItem = retryingKeys.has(itemRetryKey);
1103
- 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) => {
1104
- if (e.key === "Enter" || e.key === " ") {
1105
- e.preventDefault();
1106
- onToggleItem(itemId);
1107
- }
1108
- }, 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) => {
1109
- event.stopPropagation();
1110
- void onRetryJobs(batchId, batchProvider, itemErrorJobs, itemRetryKey);
1111
- }, 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) => {
1112
- const lang = languageMap.get((job.targetLanguage || "").toLowerCase());
1113
- const retryKey = `job:${job.id ?? `${job.itemId}:${job.sourceLanguage}:${job.targetLanguage}`}`;
1114
- 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}`));
1115
- }) }))] }, itemId));
1116
- }) }));
1384
+ return (_jsx("div", { ref: itemTableContainerRef, className: "w-full overflow-hidden", "data-language-layout": stackLanguageChips ? "stacked" : "columns", children: _jsxs("table", { ref: itemTableRef, className: "w-full border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [!stackLanguageChips && (_jsx("th", { "aria-hidden": "true", className: "px-3 pt-3 pb-1 md:px-4" })), _jsx("th", { scope: "col", className: `pt-3 pb-1 text-left align-top text-[11px] font-medium text-neutral-grey-50 ${stackLanguageChips ? "pr-2 pl-2.5" : "pr-4"}`, children: _jsxs("span", { className: "flex min-w-0 items-center gap-2.5", children: [_jsx("span", { className: "h-1.5 w-1.5 shrink-0", "aria-hidden": "true" }), _jsx("span", { children: "Pages / Items" })] }) }), !stackLanguageChips && (_jsx("th", { scope: "col", className: "pt-3 pb-1 pr-2 text-left align-top text-[11px] font-medium text-neutral-grey-50", children: "Languages" })), !stackLanguageChips && (_jsx("th", { "aria-hidden": "true", className: "pt-3 pr-3 pb-1 md:pr-4" }))] }) }), _jsx("tbody", { children: Array.from(byItem.entries()).map(([itemId, itemJobs], itemIndex) => {
1385
+ const isItemExpanded = expandedItems.has(itemId);
1386
+ const itemAnyInProgress = itemJobs.some((j) => j.status === "In Progress");
1387
+ const itemAnyPending = itemJobs.some((j) => j.status === "Pending");
1388
+ const itemIsQueued = !itemAnyInProgress && itemAnyPending;
1389
+ const status = itemStatusDot(itemJobs);
1390
+ const firstJob = itemJobs[0];
1391
+ const itemPath = firstJob?.itemPath ?? null;
1392
+ const serverName = firstJob?.itemName ?? null;
1393
+ const metadataName = parseItemName(firstJob?.metadata, "");
1394
+ const displayName = serverName || metadataName || itemId;
1395
+ const titleAttr = `${itemPath ?? ""}\n${itemId}`.trim();
1396
+ const itemErrorJobs = itemJobs.filter(isRetriableJob);
1397
+ const itemRetryKey = `item:${batchId}:${itemId}`;
1398
+ const isRetryingItem = retryingKeys.has(itemRetryKey);
1399
+ const rowLanguages = itemJobs
1400
+ .map((job) => job.targetLanguage || job.sourceLanguage)
1401
+ .filter((language) => Boolean(language));
1402
+ const normalizedCurrentLanguage = normalizeLanguageCode(currentLanguage);
1403
+ const rowOpenLanguage = rowLanguages.find((language) => normalizeLanguageCode(language) === normalizedCurrentLanguage) ?? rowLanguages[0];
1404
+ const languageChips = (_jsx(InlineLanguageJobChips, { batchId: batchId, batchProvider: batchProvider, jobs: itemJobs, languageMap: languageMap, isExpanded: isItemExpanded, batchStartedAtUtc: batchStartedAtUtc, retryingKeys: retryingKeys, singleLine: !stackLanguageChips, onToggle: () => onToggleItem(itemId), onOpenItem: onOpenItem, onRetryJobs: onRetryJobs }));
1405
+ const showRetryAction = itemErrorJobs.length > 0;
1406
+ const retryAction = showRetryAction ? (_jsx("div", { className: "flex shrink-0 items-center justify-end", children: _jsx(SimpleIconButton, { onClick: (event) => {
1407
+ event.stopPropagation();
1408
+ if (itemErrorJobs.length === 0)
1409
+ return;
1410
+ void onRetryJobs(batchId, batchProvider, itemErrorJobs, itemRetryKey);
1411
+ }, icon: _jsx(RetryIcon, { className: `h-3.5 w-3.5 ${isRetryingItem ? "animate-spin" : ""}`, strokeWidth: 1.5 }), label: "Retry failed translations for this item", disabled: itemErrorJobs.length === 0 || isRetryingItem }) })) : null;
1412
+ const itemContent = (_jsxs("div", { className: `flex min-w-0 items-start ${stackLanguageChips ? "gap-2" : "gap-2.5"}`, 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: [_jsx("span", { className: `block min-w-0 text-[13px] text-neutral-grey-100 ${stackLanguageChips ? "break-words" : "truncate"}`, style: stackLanguageChips
1413
+ ? { overflowWrap: "anywhere" }
1414
+ : undefined, title: titleAttr, children: displayName }), itemPath && (_jsx("div", { className: "whitespace-normal break-words font-mono text-[11px] text-neutral-grey-50", style: { overflowWrap: "anywhere" }, title: titleAttr, children: itemPath })), stackLanguageChips && (_jsx("div", { className: "mt-2", children: languageChips })), stackLanguageChips && retryAction && (_jsx("div", { className: "mt-2 flex justify-start", children: retryAction }))] })] }));
1415
+ return (_jsxs("tr", { role: "button", tabIndex: 0, className: `group cursor-pointer transition-colors hover:bg-neutral-grey-5 ${itemIndex > 0 ? "border-t border-border-default/50" : ""}`, onClick: () => {
1416
+ void onOpenItem(itemId, rowOpenLanguage);
1417
+ }, onKeyDown: (e) => {
1418
+ if (e.key === "Enter" || e.key === " ") {
1419
+ e.preventDefault();
1420
+ void onOpenItem(itemId, rowOpenLanguage);
1421
+ }
1422
+ }, children: [!stackLanguageChips && (_jsx("td", { "aria-hidden": "true", className: "px-3 py-2 md:px-4" })), _jsx("td", { className: `py-2 align-top ${stackLanguageChips ? "pr-2 pl-2.5" : "pr-4"}`, children: itemContent }), !stackLanguageChips && (_jsx("td", { className: "py-2 pr-2 align-top", children: languageChips })), !stackLanguageChips && (_jsx("td", { className: "py-2 pr-3 align-top md:pr-4", children: retryAction }))] }, itemId));
1423
+ }) })] }) }));
1117
1424
  }
1118
1425
  function LanguageJobChip({ job, language, batchStartedAtUtc, onOpen, isRetrying = false, onRetry, }) {
1119
1426
  const [open, setOpen] = React.useState(false);
@@ -1131,16 +1438,16 @@ function LanguageJobChip({ job, language, batchStartedAtUtc, onOpen, isRetrying
1131
1438
  ? "border-highlight-100/30 bg-highlight-10 text-highlight-100 hover:brightness-95"
1132
1439
  : jobPending
1133
1440
  ? "border-border-default bg-neutral-grey-5 text-neutral-grey-50 hover:bg-neutral-grey-5"
1134
- : "border-border-default bg-background text-neutral-grey-100 hover:bg-neutral-grey-5";
1441
+ : "border-feedback-green bg-feedback-green-light text-feedback-green hover:border-feedback-green hover:brightness-95";
1135
1442
  const statusBadgeCls = jobError
1136
- ? "bg-feedback-red-light text-feedback-red ring-1 ring-inset ring-feedback-red"
1443
+ ? "bg-feedback-red-light text-feedback-red"
1137
1444
  : jobAborted
1138
- ? "bg-feedback-orange-light text-feedback-orange ring-1 ring-inset ring-feedback-orange"
1445
+ ? "bg-feedback-orange-light text-feedback-orange"
1139
1446
  : jobInProgress
1140
- ? "bg-highlight-10 text-highlight-100 ring-1 ring-inset ring-highlight-100/20"
1447
+ ? "bg-highlight-10 text-highlight-100"
1141
1448
  : jobPending
1142
- ? "bg-neutral-grey-5 text-neutral-grey-50 ring-1 ring-inset ring-border-default"
1143
- : "bg-feedback-green-light text-feedback-green ring-1 ring-inset ring-feedback-green";
1449
+ ? "bg-neutral-grey-5 text-neutral-grey-50"
1450
+ : "bg-feedback-green-light text-feedback-green";
1144
1451
  const timestamp = job.timestamp ? new Date(job.timestamp) : null;
1145
1452
  const timestampValid = timestamp && !isNaN(timestamp.getTime());
1146
1453
  const claimedAt = job.claimedAtUtc ? new Date(job.claimedAtUtc) : null;
@@ -1198,9 +1505,9 @@ function LanguageJobChip({ job, language, batchStartedAtUtc, onOpen, isRetrying
1198
1505
  const emptyFieldText = statistics && statistics.emptyFieldCount > 0
1199
1506
  ? `${formatCount(statistics.emptyFieldCount)} empty`
1200
1507
  : null;
1201
- return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `inline-flex items-center gap-1.5 rounded-md border badge-pad-sm text-[12px] transition-colors cursor-pointer ${chipCls}`, children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-3.5 shrink-0" })) : null, _jsx("span", { className: "font-mono tracking-tight", children: job.targetLanguage }), jobInProgress && (_jsx(LoaderIcon, { className: "h-3 w-3 shrink-0 animate-spin" })), jobPending && (_jsx(QueuedIcon, { className: "h-3 w-3 shrink-0", strokeWidth: 1.75, "aria-label": "Queued" })), jobError && _jsx(AlertCircleIcon, { className: "h-3 w-3 shrink-0" }), !jobInProgress && !jobError && !jobPending && !jobAborted && (_jsx(CheckIcon, { className: "h-3 w-3 shrink-0 text-feedback-green" }))] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-[22rem] max-w-[calc(100vw-2rem)] p-0", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border-default px-3 py-2", children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-4 shrink-0" })) : null, _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[13px] font-medium text-neutral-grey-100 truncate", children: langName }), _jsxs("div", { className: "text-[11px] text-neutral-grey-50 font-mono truncate", children: [job.targetLanguage, job.sourceLanguage ? ` ← ${job.sourceLanguage}` : ""] })] }), _jsxs(Badge, { size: "sm", className: statusBadgeCls, children: [jobInProgress && (_jsx(LoaderIcon, { className: "h-2.5 w-2.5 animate-spin" })), jobStatus || "Unknown"] })] }), _jsxs("dl", { className: "divide-y divide-border-default/60", children: [claimedAtValid && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Claimed" }), _jsx("dd", { className: "text-[12px] text-neutral-grey-100", children: claimedAt.toLocaleString() })] })), timestampValid && (jobInProgress || jobPending) && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Updated" }), _jsx("dd", { className: "text-[12px] text-neutral-grey-100", children: timestamp.toLocaleString() })] })), durationMs != null && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: jobInProgress || jobPending ? "Running" : "Duration" }), _jsxs("dd", { className: "text-[12px] tabular-nums text-neutral-grey-100", title: durationApproximated
1508
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `group inline-flex items-center gap-1.5 rounded border badge-pad-sm text-[12px] transition-colors cursor-pointer ${chipCls}`, children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-3.5 shrink-0" })) : null, _jsx("span", { className: "font-mono tracking-tight", children: job.targetLanguage }), jobInProgress && (_jsx(LoaderIcon, { className: "h-3 w-3 shrink-0 animate-spin" })), jobPending && (_jsx(QueuedIcon, { className: "h-3 w-3 shrink-0", strokeWidth: 1.75, "aria-label": "Queued" })), jobError && _jsx(AlertCircleIcon, { className: "h-3 w-3 shrink-0" }), !jobInProgress && !jobError && !jobPending && !jobAborted && (_jsx(CheckIcon, { className: "h-3 w-3 shrink-0 text-feedback-green" }))] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-[22rem] max-w-[calc(100vw-2rem)] p-0", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border-default px-3 py-2", children: [language?.icon ? (_jsx("img", { src: language.icon, alt: langName, className: "h-4 shrink-0" })) : null, _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[13px] font-medium text-neutral-grey-100 truncate", children: langName }), _jsxs("div", { className: "text-[11px] text-neutral-grey-50 font-mono truncate", children: [job.targetLanguage, job.sourceLanguage ? ` ← ${job.sourceLanguage}` : ""] })] }), _jsxs(Badge, { size: "sm", className: statusBadgeCls, children: [jobInProgress && (_jsx(LoaderIcon, { className: "h-2.5 w-2.5 animate-spin" })), jobStatus || "Unknown"] })] }), _jsxs("dl", { className: "divide-y divide-border-default/60", children: [claimedAtValid && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Claimed" }), _jsx("dd", { className: "text-[12px] text-neutral-grey-100", children: formatDateTime(claimedAt) })] })), 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: formatDateTime(timestamp) })] })), 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
1202
1509
  ? "Approximate — measured from batch start"
1203
- : 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 &&
1510
+ : 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: formatDateTime(lastHeartbeat) })] })), 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 &&
1204
1511
  (jobError || jobAborted ? (
1205
1512
  // Error/abort messages can be long and multi-line (e.g. an external
1206
1513
  // ITranslationService stack trace). Render them in a bounded,