@parhelia/localization 0.4.12796 → 0.4.12818

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 +1 @@
1
- {"version":3,"file":"LocalizeItemCommand.d.ts","sourceRoot":"","sources":["../src/LocalizeItemCommand.tsx"],"names":[],"mappings":"AACA,OAAO,EACL,OAAO,EACP,cAAc,EACd,WAAW,EACX,eAAe,EACf,QAAQ,EACT,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAEL,uBAAuB,EACxB,MAAM,eAAe,CAAC;AAQvB,MAAM,MAAM,uBAAuB,GAAG,WAAW,GAAG;IAClD,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,0BAA0B,GACpC,cAAc,CAAC,uBAAuB,CAAC,CAAC;AAC1C,MAAM,MAAM,mBAAmB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;AAEnE,wBAAsB,sBAAsB,CAAC,EAC3C,WAAW,EACX,UAAmC,EACnC,KAAK,GACN,EAAE;IACD,WAAW,EAAE,eAAe,CAAC;IAC7B,UAAU,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,CAAC;IAC3C,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,2CAiCA;AAED,eAAO,MAAM,mBAAmB,EAAE,mBA6BjC,CAAC"}
1
+ {"version":3,"file":"LocalizeItemCommand.d.ts","sourceRoot":"","sources":["../src/LocalizeItemCommand.tsx"],"names":[],"mappings":"AACA,OAAO,EACL,OAAO,EACP,cAAc,EACd,WAAW,EACX,eAAe,EACf,QAAQ,EACT,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAEL,uBAAuB,EACxB,MAAM,eAAe,CAAC;AAQvB,MAAM,MAAM,uBAAuB,GAAG,WAAW,GAAG;IAClD,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,0BAA0B,GACpC,cAAc,CAAC,uBAAuB,CAAC,CAAC;AAC1C,MAAM,MAAM,mBAAmB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;AAEnE,wBAAsB,sBAAsB,CAAC,EAC3C,WAAW,EACX,UAAmC,EACnC,KAAK,GACN,EAAE;IACD,WAAW,EAAE,eAAe,CAAC;IAC7B,UAAU,CAAC,EAAE,eAAe,CAAC,YAAY,CAAC,CAAC;IAC3C,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB,2CAiCA;AAED,eAAO,MAAM,mBAAmB,EAAE,mBAkCjC,CAAC"}
@@ -39,6 +39,11 @@ export const localizeItemCommand = {
39
39
  if (!context.data?.items || context.data.items.length === 0) {
40
40
  return true;
41
41
  }
42
+ // Translation writes the target versions, so a selection where the user
43
+ // cannot write any item has nothing the wizard could do.
44
+ if (context.data.items.every((item) => item.canWriteItem === false)) {
45
+ return true;
46
+ }
42
47
  // Check if multi-item is disabled and multiple items are selected
43
48
  const multiItemEnabled = context.editContext?.configuration?.localization?.multiItem !==
44
49
  false;
@@ -1 +1 @@
1
- {"version":3,"file":"useTranslationWizard.d.ts","sourceRoot":"","sources":["../../src/hooks/useTranslationWizard.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAUhF,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,uBAAuB;;;EAsGlE"}
1
+ {"version":3,"file":"useTranslationWizard.d.ts","sourceRoot":"","sources":["../../src/hooks/useTranslationWizard.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAUhF,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,uBAAuB;;;EAuGlE"}
@@ -45,6 +45,7 @@ export function useTranslationWizard(props) {
45
45
  name: language.name,
46
46
  languageCode: language.languageCode,
47
47
  items: [],
48
+ canWrite: language.canWrite,
48
49
  };
49
50
  if (language.versions > 0 && itemDescriptors[itemIndex]) {
50
51
  languageData.items.push(itemDescriptors[itemIndex]);
@@ -1 +1 @@
1
- {"version":3,"file":"ItemSelectionTree.d.ts","sourceRoot":"","sources":["../../src/steps/ItemSelectionTree.tsx"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,eAAe,EAMrB,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAGrD,KAAK,sBAAsB,GAAG;IAC5B,IAAI,EAAE,qBAAqB,CAAC;IAC5B,OAAO,EAAE,CAAC,IAAI,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAC/C,WAAW,EAAE,eAAe,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B,CAAC;AAsEF,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,OAAO,EACP,WAAW,EACX,QAAe,EACf,sBAAsB,EACtB,MAAY,GACb,EAAE,sBAAsB,+BAiWxB"}
1
+ {"version":3,"file":"ItemSelectionTree.d.ts","sourceRoot":"","sources":["../../src/steps/ItemSelectionTree.tsx"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,eAAe,EAMrB,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAGrD,KAAK,sBAAsB,GAAG;IAC5B,IAAI,EAAE,qBAAqB,CAAC;IAC5B,OAAO,EAAE,CAAC,IAAI,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAC/C,WAAW,EAAE,eAAe,CAAC;IAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC1B,CAAC;AAuFF,wBAAgB,iBAAiB,CAAC,EAChC,IAAI,EACJ,OAAO,EACP,WAAW,EACX,QAAe,EACf,sBAAsB,EACtB,MAAY,GACb,EAAE,sBAAsB,+BA0WxB"}
@@ -2,7 +2,20 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { ItemCollectionEditor, } from "@parhelia/core";
4
4
  import { streamSubitemCount } from "../api/discovery";
5
- const SITECORE_ROOT = "11111111-1111-1111-1111-111111111111";
5
+ const TRANSLATION_ROOT_ITEM_IDS = [
6
+ "0de95ae4-41ab-4d01-9eb0-67441b7c2450", // Content
7
+ "b701850a-cb8a-4943-b2bc-dddb1238c103", // Forms
8
+ "3d6658d8-a0bf-4e75-b3e2-d050fabcf4e1", // Media Library
9
+ ];
10
+ const NO_WRITE_ACCESS_TITLE = "You don't have write access to this item, so it can't be translated.";
11
+ /**
12
+ * Only items the user may write can receive a translation (the backend
13
+ * rejects the rest at start time). Sources that don't report the flag are
14
+ * treated as allowed and left to the backend check.
15
+ */
16
+ function isWritable(item) {
17
+ return item.canWrite !== false && item.canWriteItem !== false;
18
+ }
6
19
  function fullItemFromDescriptor(descriptor) {
7
20
  return {
8
21
  id: descriptor.id,
@@ -24,7 +37,7 @@ function fullItemFromDescriptor(descriptor) {
24
37
  thumbnail: "",
25
38
  path: descriptor.path ?? "",
26
39
  fields: [],
27
- canWriteItem: false,
40
+ canWriteItem: descriptor.canWrite !== false,
28
41
  canWriteLanguage: false,
29
42
  canWriteWorkflow: false,
30
43
  canRename: false,
@@ -68,7 +81,11 @@ export function ItemSelectionTree({ data, setData, editContext, isActive = true,
68
81
  const seed = data.discoveredItems && data.discoveredItems.length > 0
69
82
  ? data.discoveredItems
70
83
  : data.items;
71
- return seed.map((item) => fullItemToWithSubtree(item, !!data.includeSubitems));
84
+ // The command can be invoked on a read-only item (context menu, sidebar);
85
+ // such seeds are dropped so the list never holds an untranslatable item.
86
+ return seed
87
+ .filter(isWritable)
88
+ .map((item) => fullItemToWithSubtree(item, !!data.includeSubitems));
72
89
  // First mount only — once the user edits, wizard data is downstream.
73
90
  // eslint-disable-next-line react-hooks/exhaustive-deps
74
91
  }, []);
@@ -254,6 +271,7 @@ export function ItemSelectionTree({ data, setData, editContext, isActive = true,
254
271
  icon: anyNode.icon,
255
272
  templateId: anyNode.templateId,
256
273
  hasChildren: anyNode.hasChildren,
274
+ canWrite: anyNode.canWrite,
257
275
  }));
258
276
  }, [language]);
259
277
  const handleAddToList = useCallback(async (itemsToAdd) => {
@@ -265,6 +283,8 @@ export function ItemSelectionTree({ data, setData, editContext, isActive = true,
265
283
  for (const node of source) {
266
284
  if (!node.id || existing.has(node.id))
267
285
  continue;
286
+ if (!isWritable(node))
287
+ continue;
268
288
  const nodeWithIcon = node;
269
289
  cacheTreeNode(node);
270
290
  next.push({
@@ -290,6 +310,8 @@ export function ItemSelectionTree({ data, setData, editContext, isActive = true,
290
310
  const id = item.id;
291
311
  if (!id || items.some((i) => i.descriptor.id === id))
292
312
  return;
313
+ if (!isWritable(item))
314
+ return;
293
315
  cacheTreeNode(item);
294
316
  const next = [
295
317
  ...items,
@@ -328,5 +350,5 @@ export function ItemSelectionTree({ data, setData, editContext, isActive = true,
328
350
  setItems(next);
329
351
  commitToWizard(next);
330
352
  }, [items, commitToWizard]);
331
- return (_jsx(ItemCollectionEditor, { items: items, selectedInTree: selectedInTree, onSelectedInTreeChange: setSelectedInTree, selectedFromList: selectedFromList, onSelectedFromListChange: setSelectedFromList, onAddToList: handleAddToList, onRemoveFromList: handleRemoveFromList, onAddItem: handleAddItem, onRemoveItem: handleRemoveItem, onToggleSubitems: handleToggleSubitems, language: language, rootItemIds: [SITECORE_ROOT], selectedItemsLabel: "Items to Translate", emptyMessage: "No items selected", emptyHint: "Browse or search to add items", height: height, subitemCounts: subitemCounts, expandIdPath: expandIdPath, localStorageKey: "translation-wizard.itemCollectionSplitter", treeSearchContainerClassName: "border-b-0 p-0 pb-3", frameless: true }));
353
+ return (_jsx(ItemCollectionEditor, { items: items, selectedInTree: selectedInTree, onSelectedInTreeChange: setSelectedInTree, selectedFromList: selectedFromList, onSelectedFromListChange: setSelectedFromList, onAddToList: handleAddToList, onRemoveFromList: handleRemoveFromList, onAddItem: handleAddItem, onRemoveItem: handleRemoveItem, onToggleSubitems: handleToggleSubitems, language: language, rootItemIds: TRANSLATION_ROOT_ITEM_IDS, hideRootNodes: false, isItemSelectable: isWritable, unselectableItemTitle: NO_WRITE_ACCESS_TITLE, selectedItemsLabel: "Items to Translate", emptyMessage: "No items selected", emptyHint: "Browse or search to add items", height: height, subitemCounts: subitemCounts, expandIdPath: expandIdPath, localStorageKey: "translation-wizard.itemCollectionSplitter", treeSearchContainerClassName: "border-b-0 p-0 pb-3", frameless: true }));
332
354
  }
@@ -228,5 +228,5 @@ export function ProviderStep({ isActive = true, data, setData, onStepCompleted,
228
228
  }), options: data.translationProviders.map((provider) => ({
229
229
  value: provider.name,
230
230
  label: provider.displayName || provider.name,
231
- })), placeholder: "Select a provider...", size: "sm", className: "mt-2 w-full", "data-testid": "translation-provider-select" }), isCreateLanguageVersionsOnlyProvider && (_jsx("p", { className: "text-muted-foreground mt-2 text-xs", children: "\"Create Language Versions Only\" will create new language versions without automatic translation." }))] }), supportsPromptCustomization && (_jsxs("div", { children: [_jsxs(Label, { className: "flex cursor-pointer items-start gap-2.5", children: [_jsx("input", { type: "checkbox", checked: enableCustomPrompt, onChange: (e) => handleCustomPromptToggle(e.target.checked), className: "mt-0.5 h-3.5 w-3.5 rounded border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)]", "data-testid": "enable-custom-prompt-checkbox" }), _jsxs("div", { className: "min-w-0", children: [_jsx("span", { className: "block text-[13px] font-medium text-neutral-grey-100", children: "Customize translation prompt (optional)" }), _jsx("span", { className: "text-muted-foreground mt-0.5 block text-xs", children: "Edit the provider prompt before starting translation." })] })] }), enableCustomPrompt && (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsx(Textarea, { id: "custom-prompt-textarea", value: customPrompt, onChange: (e) => setCustomPrompt(e.target.value), className: "text-xs", rows: 8, placeholder: "Enter prompt instructions here...", "data-testid": "custom-prompt-textarea" }), hasDefaultPrompt && (_jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: handleResetPrompt, disabled: customPrompt === (defaultPrompt || ""), className: "gap-1.5", title: "Reset to the default prompt", "data-testid": "reset-custom-prompt-button", children: [_jsx(ResetIcon, { className: "size-3.5", strokeWidth: 1, "aria-hidden": "true" }), "Reset to default"] }))] }))] }))] })] }) }) }));
231
+ })), placeholder: "Select a provider...", size: "sm", className: "mt-2 w-full", "data-testid": "translation-provider-select" }), isCreateLanguageVersionsOnlyProvider && (_jsx("p", { className: "text-muted-foreground mt-2 text-xs", children: "\"Create Language Versions Only\" will create new language versions without automatic translation." }))] }), supportsPromptCustomization && (_jsxs("div", { children: [_jsxs(Label, { className: "flex cursor-pointer items-start gap-2.5", children: [_jsx("input", { type: "checkbox", checked: enableCustomPrompt, onChange: (e) => handleCustomPromptToggle(e.target.checked), className: "mt-0.5 h-3.5 w-3.5 rounded border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)]", "data-testid": "enable-custom-prompt-checkbox" }), _jsxs("div", { className: "min-w-0", children: [_jsx("span", { className: "block text-[13px] font-medium text-neutral-grey-100", children: "Customize translation prompt (optional)" }), _jsx("span", { className: "text-muted-foreground mt-0.5 block text-xs", children: "Edit the provider prompt before starting translation." })] })] }), enableCustomPrompt && (_jsxs("div", { className: "mt-4 space-y-2", children: [_jsx(Textarea, { id: "custom-prompt-textarea", value: customPrompt, onChange: (e) => setCustomPrompt(e.target.value), className: "text-xs", rows: 8, placeholder: "Add instructions for the translator, e.g. tone of voice, terms to keep untranslated, or formality.", "data-testid": "custom-prompt-textarea" }), hasDefaultPrompt && (_jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: handleResetPrompt, disabled: customPrompt === (defaultPrompt || ""), className: "gap-1.5", title: "Reset to the default prompt", "data-testid": "reset-custom-prompt-button", children: [_jsx(ResetIcon, { className: "size-3.5", strokeWidth: 1, "aria-hidden": "true" }), "Reset to default"] }))] }))] }))] })] }) }) }));
232
232
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ServiceLanguageSelectionStep.d.ts","sourceRoot":"","sources":["../../src/steps/ServiceLanguageSelectionStep.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAyB,MAAM,SAAS,CAAC;AAGtE;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,EAC3C,QAAe,EACf,IAAI,EACJ,OAAO,EACP,eAAe,EACf,WAAW,GACZ,EAAE,oBAAoB,+BA+RtB"}
1
+ {"version":3,"file":"ServiceLanguageSelectionStep.d.ts","sourceRoot":"","sources":["../../src/steps/ServiceLanguageSelectionStep.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAyB,MAAM,SAAS,CAAC;AAGtE;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,EAC3C,QAAe,EACf,IAAI,EACJ,OAAO,EACP,eAAe,EACf,WAAW,GACZ,EAAE,oBAAoB,+BAkUtB"}
@@ -82,6 +82,12 @@ export function ServiceLanguageSelectionStep({ isActive = true, data, setData, o
82
82
  data.languageData.get(sourceLanguage)?.name ||
83
83
  siteLanguageMap.get(sourceLanguage)?.name ||
84
84
  sourceLanguage;
85
+ // Language write ACL (language:write). Every source reports it for
86
+ // the same language item, so the first defined value wins; unknown
87
+ // is treated as allowed and left to the backend check.
88
+ const canWrite = editContextLang?.canWrite ??
89
+ languageDataLang?.canWrite ??
90
+ siteLanguage?.canWrite;
85
91
  return {
86
92
  code,
87
93
  name: editContextLang?.name ||
@@ -91,6 +97,7 @@ export function ServiceLanguageSelectionStep({ isActive = true, data, setData, o
91
97
  icon: editContextLang?.icon || siteLanguage?.icon,
92
98
  sourceLanguage,
93
99
  sourceLanguageName,
100
+ canWrite: canWrite !== false,
94
101
  };
95
102
  })
96
103
  .filter((lang) => lang.sourceLanguage !== lang.code);
@@ -114,7 +121,8 @@ export function ServiceLanguageSelectionStep({ isActive = true, data, setData, o
114
121
  const initialSelection = {};
115
122
  for (const langCode of data.targetLanguages) {
116
123
  const lang = allLanguages.find((l) => l.code === langCode);
117
- if (lang)
124
+ // Predefined targets the user cannot write are dropped silently.
125
+ if (lang?.canWrite)
118
126
  initialSelection[langCode] = true;
119
127
  }
120
128
  setLanguageSelection(initialSelection);
@@ -137,37 +145,47 @@ export function ServiceLanguageSelectionStep({ isActive = true, data, setData, o
137
145
  };
138
146
  setData(newData);
139
147
  }, [languageSelection, setData, data.targetLanguages]);
148
+ // Only languages the user may write take part in toggling / select-all;
149
+ // the others render disabled below.
150
+ const writableLanguages = useMemo(() => allLanguages.filter((lang) => lang.canWrite), [allLanguages]);
140
151
  const handleLanguageToggle = (langCode) => {
152
+ if (!writableLanguages.some((lang) => lang.code === langCode))
153
+ return;
141
154
  setLanguageSelection((prev) => ({ ...prev, [langCode]: !prev[langCode] }));
142
155
  };
143
156
  const handleSelectAllLanguages = () => {
144
- const allSelected = allLanguages.every((lang) => languageSelection[lang.code]);
157
+ const allSelected = writableLanguages.every((lang) => languageSelection[lang.code]);
145
158
  const newSelection = {};
146
- allLanguages.forEach((lang) => {
159
+ writableLanguages.forEach((lang) => {
147
160
  newSelection[lang.code] = !allSelected;
148
161
  });
149
162
  setLanguageSelection(newSelection);
150
163
  };
151
164
  const areAllLanguagesSelected = useMemo(() => {
152
- if (allLanguages.length === 0)
165
+ if (writableLanguages.length === 0)
153
166
  return false;
154
- return allLanguages.every((lang) => languageSelection[lang.code]);
155
- }, [allLanguages, languageSelection]);
167
+ return writableLanguages.every((lang) => languageSelection[lang.code]);
168
+ }, [writableLanguages, languageSelection]);
156
169
  const areSomeLanguagesSelected = useMemo(() => {
157
- return allLanguages.some((lang) => languageSelection[lang.code]);
158
- }, [allLanguages, languageSelection]);
170
+ return writableLanguages.some((lang) => languageSelection[lang.code]);
171
+ }, [writableLanguages, languageSelection]);
159
172
  const selectedCount = data.targetLanguages.length;
160
- return (_jsx(WizardStepShell, { fillHeight: true, noPadding: true, testId: "language-selection-step", children: _jsxs("div", { className: "flex min-h-0 w-full flex-1 flex-col overflow-hidden p-6", children: [_jsxs("div", { className: "flex shrink-0 items-start justify-between gap-2 pb-3", children: [_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "text-xs font-medium tracking-wider text-neutral-grey-50", children: "Select target languages" }), selectedCount > 0 && _jsx(CountBadge, { children: selectedCount })] }), allLanguages.length > 1 && (_jsxs("label", { className: "flex cursor-pointer items-center gap-1.5 text-[11px] font-medium text-neutral-grey-50 transition-colors hover:text-neutral-grey-100", children: [_jsx("input", { type: "checkbox", checked: areAllLanguagesSelected, ref: (input) => {
173
+ return (_jsx(WizardStepShell, { fillHeight: true, noPadding: true, testId: "language-selection-step", children: _jsxs("div", { className: "flex min-h-0 w-full flex-1 flex-col overflow-hidden p-6", children: [_jsxs("div", { className: "flex shrink-0 items-start justify-between gap-2 pb-3", children: [_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: "text-xs font-medium tracking-wider text-neutral-grey-50", children: "Select target languages" }), selectedCount > 0 && _jsx(CountBadge, { children: selectedCount })] }), writableLanguages.length > 1 && (_jsxs("label", { className: "flex cursor-pointer items-center gap-1.5 text-[11px] font-medium text-neutral-grey-50 transition-colors hover:text-neutral-grey-100", children: [_jsx("input", { type: "checkbox", checked: areAllLanguagesSelected, ref: (input) => {
161
174
  if (input) {
162
175
  input.indeterminate =
163
176
  areSomeLanguagesSelected && !areAllLanguagesSelected;
164
177
  }
165
178
  }, onChange: handleSelectAllLanguages, className: "h-3 w-3 rounded border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)]", "data-testid": "select-all-languages-checkbox" }), "Select all"] }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pt-2", children: allLanguages.length === 0 ? (_jsx("div", { className: "grid gap-2.5 sm:grid-cols-2", children: [...Array(6)].map((_, i) => (_jsxs("div", { className: "flex animate-pulse items-center gap-2 rounded-[8px] border border-border-default px-3 py-2", children: [_jsx("div", { className: "h-3.5 w-3.5 rounded bg-neutral-grey-10" }), _jsx("div", { className: "h-4 w-4 bg-neutral-grey-10" }), _jsx("div", { className: "h-3.5 w-24 rounded bg-neutral-grey-10" }), _jsx("div", { className: "ml-auto h-3 w-10 rounded bg-neutral-grey-5" })] }, i))) })) : (_jsx("div", { className: "grid gap-2.5 sm:grid-cols-2", children: allLanguages.map((lang) => {
166
179
  const checked = !!languageSelection[lang.code];
167
- return (_jsxs("label", { className: `group flex cursor-pointer items-center gap-2.5 rounded-[8px] border px-2 py-1.5 transition-all ${checked
168
- ? "border-neutral-grey-100"
169
- : "border-border-default hover:border-border-default hover:bg-neutral-grey-5"}`, children: [_jsx("input", { type: "checkbox", checked: checked, onChange: () => handleLanguageToggle(lang.code), className: "h-3.5 w-3.5 shrink-0 rounded border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)]", "data-testid": `language-checkbox-${lang.code}` }), lang.icon ? (_jsx("img", { src: lang.icon, alt: "", "aria-hidden": "true", className: "h-4 w-5 shrink-0 object-cover" })) : (_jsx("span", { className: "inline-block h-4 w-5 shrink-0 bg-neutral-grey-5" })), _jsx("span", { className: `min-w-0 flex-1 truncate text-[13px] ${checked
180
+ const readOnly = !lang.canWrite;
181
+ return (_jsxs("label", { title: readOnly
182
+ ? "You don't have write access to this language."
183
+ : undefined, "data-testid": `language-option-${lang.code}`, "data-readonly": readOnly || undefined, className: `group flex items-center gap-2.5 rounded-[8px] border px-2 py-1.5 transition-all ${readOnly
184
+ ? "cursor-not-allowed border-border-default opacity-50"
185
+ : checked
186
+ ? "cursor-pointer border-neutral-grey-100"
187
+ : "cursor-pointer border-border-default hover:border-border-default hover:bg-neutral-grey-5"}`, children: [_jsx("input", { type: "checkbox", checked: checked, disabled: readOnly, onChange: () => handleLanguageToggle(lang.code), className: "h-3.5 w-3.5 shrink-0 rounded border-border-default text-[var(--color-highlight-100)] accent-[var(--color-highlight-100)] focus:ring-[var(--color-highlight-100)] disabled:cursor-not-allowed", "data-testid": `language-checkbox-${lang.code}` }), lang.icon ? (_jsx("img", { src: lang.icon, alt: "", "aria-hidden": "true", className: "h-4 w-5 shrink-0 object-cover" })) : (_jsx("span", { className: "inline-block h-4 w-5 shrink-0 bg-neutral-grey-5" })), _jsx("span", { className: `min-w-0 flex-1 truncate text-[13px] ${checked
170
188
  ? "font-medium text-neutral-grey-100"
171
- : "text-neutral-grey-100"}`, children: lang.name }), _jsxs("span", { className: "ml-auto flex max-w-[9rem] shrink-0 flex-col items-end leading-tight", children: [_jsx("span", { className: "font-mono text-[10px] tracking-wider text-neutral-grey-50", children: lang.code }), _jsxs("span", { className: "max-w-full truncate text-[10px] text-neutral-grey-50", title: `Source language: ${lang.sourceLanguageName}`, children: ["from ", lang.sourceLanguageName] })] })] }, lang.code));
189
+ : "text-neutral-grey-100"}`, children: lang.name }), _jsxs("span", { className: "ml-auto flex max-w-[9rem] shrink-0 flex-col items-end leading-tight", children: [_jsx("span", { className: "font-mono text-[10px] tracking-wider text-neutral-grey-50", children: lang.code }), readOnly ? (_jsx("span", { className: "max-w-full truncate text-[10px] text-neutral-grey-50", children: "no write access" })) : (_jsxs("span", { className: "max-w-full truncate text-[10px] text-neutral-grey-50", title: `Source language: ${lang.sourceLanguageName}`, children: ["from ", lang.sourceLanguageName] }))] })] }, lang.code));
172
190
  }) })) })] }) }));
173
191
  }
@@ -20,6 +20,8 @@ export type LanguageData = {
20
20
  languageCode: string;
21
21
  items: ItemDescriptor[];
22
22
  translationStatus?: TranslationStatus;
23
+ /** Language write ACL (language:write) for the current user. Undefined when not reported. */
24
+ canWrite?: boolean;
23
25
  };
24
26
  export type TranslationDialogResult = {
25
27
  translationProvider: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/steps/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/steps/types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"TranslationBatches.d.ts","sourceRoot":"","sources":["../../src/translation-center/TranslationBatches.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,MAAM,OAAO,CAAC;AAyV1B,wBAAgB,kBAAkB,sBAwnCjC;AAED,eAAO,MAAM,oCAAoC,8BAA8B,CAAC;AAEhF,wBAAgB,8BAA8B,sBA0Y7C"}
1
+ {"version":3,"file":"TranslationBatches.d.ts","sourceRoot":"","sources":["../../src/translation-center/TranslationBatches.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,MAAM,OAAO,CAAC;AAyV1B,wBAAgB,kBAAkB,sBA6qCjC;AAED,eAAO,MAAM,oCAAoC,8BAA8B,CAAC;AAEhF,wBAAgB,8BAA8B,sBA+Y7C"}
@@ -191,6 +191,11 @@ export function TranslationBatches() {
191
191
  const [batches, setBatches] = useState([]);
192
192
  const [sitecoreLanguages, setSitecoreLanguages] = useState([]);
193
193
  const [isLoading, setIsLoading] = useState(false);
194
+ // False until the first list response lands. Later reloads (the user filter
195
+ // defaulting in, socket refreshes, the reconciliation poll) update in place
196
+ // instead of swapping the view for a spinner: the home view picks its slogan
197
+ // per mount, so every swap re-rolled the greeting.
198
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
194
199
  const [isLoadingMore, setIsLoadingMore] = useState(false);
195
200
  const [hasMore, setHasMore] = useState(true);
196
201
  const [currentOffset, setCurrentOffset] = useState(0);
@@ -209,7 +214,11 @@ export function TranslationBatches() {
209
214
  // (e.g. when the user changes filters quickly).
210
215
  const requestIdRef = useRef(0);
211
216
  const currentUserName = editContext?.user?.name || "all";
212
- const isLimitedPreviewUser = editContext?.user?.isLimitedPreviewUser ?? false;
217
+ // Limited-preview sessions and accounts confined to their own batches (see
218
+ // userInfo.canViewAllTranslationBatches) get the user filter pinned to themselves;
219
+ // the server enforces the same scope on every batch endpoint.
220
+ const userFilterLocked = (editContext?.user?.isLimitedPreviewUser ?? false) ||
221
+ editContext?.userInfo?.canViewAllTranslationBatches === false;
213
222
  const isMobile = editContext?.isMobile ?? false;
214
223
  const rootRef = useRef(null);
215
224
  const [isCompactBatches, setIsCompactBatches] = useState(isMobile);
@@ -253,7 +262,7 @@ export function TranslationBatches() {
253
262
  }, [currentUserName]);
254
263
  // Enforce current user filter for limited preview users
255
264
  useEffect(() => {
256
- if (isLimitedPreviewUser &&
265
+ if (userFilterLocked &&
257
266
  currentUserName !== "all" &&
258
267
  filters.user !== currentUserName) {
259
268
  setTranslationBatchFilters((prev) => ({
@@ -261,9 +270,9 @@ export function TranslationBatches() {
261
270
  user: currentUserName,
262
271
  }));
263
272
  }
264
- }, [isLimitedPreviewUser, currentUserName, filters.user]);
273
+ }, [userFilterLocked, currentUserName, filters.user]);
265
274
  const effectiveFilters = useMemo(() => {
266
- const userFilter = isLimitedPreviewUser ? currentUserName : filters.user;
275
+ const userFilter = userFilterLocked ? currentUserName : filters.user;
267
276
  const trimmedItem = filters.itemIdOrPath.trim();
268
277
  return {
269
278
  fromUtc: dateRangeToFromUtc(filters.dateRange),
@@ -276,7 +285,7 @@ export function TranslationBatches() {
276
285
  itemIdOrPath: trimmedItem ? trimmedItem : undefined,
277
286
  itemIncludeSubitems: trimmedItem && filters.itemIncludeSubitems ? true : undefined,
278
287
  };
279
- }, [filters, isLimitedPreviewUser, currentUserName]);
288
+ }, [filters, userFilterLocked, currentUserName]);
280
289
  const loadRecentBatches = useCallback(async (offset, append, filtersToUse) => {
281
290
  const myRequestId = ++requestIdRef.current;
282
291
  if (offset === 0) {
@@ -323,6 +332,7 @@ export function TranslationBatches() {
323
332
  if (myRequestId === requestIdRef.current) {
324
333
  setIsLoading(false);
325
334
  setIsLoadingMore(false);
335
+ setHasLoadedOnce(true);
326
336
  }
327
337
  }
328
338
  }, []);
@@ -777,12 +787,18 @@ export function TranslationBatches() {
777
787
  editContext?.setShowAgentsWorkspaceEditor(true);
778
788
  await editContext?.loadItem(language ? { id: normalized, language, version: 0 } : normalized);
779
789
  }, [editContext]);
780
- const showHomeView = !(isLoading && batches.length === 0) &&
781
- groupedBatches.length === 0 &&
782
- !hasActiveEmptyStateFilters;
790
+ // The blank-slate home view (no header, grey full-height greeting) also
791
+ // covers the initial load, so the greeting mounts exactly once.
792
+ const showHomeView = groupedBatches.length === 0 && !hasActiveEmptyStateFilters;
793
+ // Only the very first load, or a filtered reload with nothing to show,
794
+ // replaces the content with a spinner.
795
+ const showLoadingPlaceholder = !hasLoadedOnce ||
796
+ (isLoading && batches.length === 0 && hasActiveEmptyStateFilters);
783
797
  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: [!showHomeView && (_jsx(PageHeader, { title: "Translation Batches", description: isMobile
784
798
  ? undefined
785
- : "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 ${showHomeView ? "" : 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-sm text-neutral-grey-50", children: [_jsx(LoaderIcon, { className: "h-5 w-5 animate-spin text-highlight-100" }), "Loading recent translations..."] }) })) : groupedBatches.length === 0 ? (hasActiveEmptyStateFilters ? (_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: emptyStateSlogan }), _jsx("p", { className: "text-sm mt-1", children: "Try adjusting your filters or start a translation to see it appear here" })] }) })) : (_jsx(WorkspaceHomeView, { slogans: EMPTY_STATE_SLOGANS, actionLabel: "Start your first translation", onAction: editContext ? handleStartFirstTranslation : undefined, testId: "translation-batches-home-view", actionTestId: "translation-batches-start-translation-card" }))) : (_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) => {
799
+ : "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 ${showHomeView ? "" : isCompactBatches ? "p-3" : "p-6"}`, children: showLoadingPlaceholder ? (_jsx("div", { className: showHomeView
800
+ ? "bg-neutral-grey-5 flex h-full min-h-[420px] w-full items-center justify-center"
801
+ : "flex items-center justify-center h-32", "data-testid": "translation-batches-loading", children: _jsxs("div", { className: "flex items-center gap-2 text-sm text-neutral-grey-50", children: [_jsx(LoaderIcon, { className: "h-5 w-5 animate-spin text-highlight-100" }), "Loading recent translations..."] }) })) : groupedBatches.length === 0 ? (hasActiveEmptyStateFilters ? (_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: emptyStateSlogan }), _jsx("p", { className: "text-sm mt-1", children: "Try adjusting your filters or start a translation to see it appear here" })] }) })) : (_jsx(WorkspaceHomeView, { slogans: EMPTY_STATE_SLOGANS, actionLabel: "Start your first translation", onAction: editContext ? handleStartFirstTranslation : undefined, testId: "translation-batches-home-view", actionTestId: "translation-batches-start-translation-card" }))) : (_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) => {
786
802
  const batchDate = new Date(b.timestamp);
787
803
  const formattedBatchDate = formatDateTime(batchDate);
788
804
  const info = b.info;
@@ -798,8 +814,13 @@ export function TranslationBatches() {
798
814
  const anyError = errorJobs > 0;
799
815
  const anyInProgress = isActiveBatchStatus(info?.status);
800
816
  const anyAborted = info?.status === "Aborted";
817
+ const jobs = batchJobs[b.id];
818
+ const statusCounts = computeBatchStatusCounts(info, jobs);
819
+ const processedJobs = statusCounts.succeeded +
820
+ statusCounts.failed +
821
+ statusCounts.aborted;
801
822
  const metaParts = [];
802
- metaParts.push(_jsxs("span", { className: "text-neutral-grey-100", children: [totalJobs, " translation", totalJobs !== 1 ? "s" : ""] }, "trans"));
823
+ metaParts.push(_jsx("span", { className: "text-neutral-grey-100", children: info?.status === "Pending" ? (_jsxs(_Fragment, { children: [totalJobs, " translation", totalJobs !== 1 ? "s" : "", " queued"] })) : anyInProgress ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "tabular-nums", children: [processedJobs, " of ", totalJobs] }), " ", "translation", totalJobs !== 1 ? "s" : "", " done"] })) : (_jsxs(_Fragment, { children: [totalJobs, " translation", totalJobs !== 1 ? "s" : ""] })) }, "trans"));
803
824
  if (typeof itemsCount === "number") {
804
825
  metaParts.push(_jsxs("span", { children: [itemsCount, " item", itemsCount !== 1 ? "s" : ""] }, "items"));
805
826
  }
@@ -850,8 +871,6 @@ export function TranslationBatches() {
850
871
  `Translation Batch · ${formattedBatchDate}`);
851
872
  })();
852
873
  const isExpanded = expandedBatchId === b.id;
853
- const jobs = batchJobs[b.id];
854
- const statusCounts = computeBatchStatusCounts(info, jobs);
855
874
  const isLoadingThis = loadingJobs.has(b.id);
856
875
  const canRetry = anyError;
857
876
  const canAbort = isActiveBatchStatus(info?.status);
@@ -880,19 +899,19 @@ export function TranslationBatches() {
880
899
  if (!canAbort)
881
900
  return;
882
901
  handleAbortBatch(b.id);
883
- }, icon: _jsx(AbortIcon, { className: `h-3.5 w-3.5 ${isAborting ? "animate-spin" : ""}`, strokeWidth: 1 }), label: "Abort translation batch", disabled: !canAbort || isAborting }))] }));
902
+ }, icon: isAborting ? (_jsx(LoaderIcon, { className: "h-3.5 w-3.5 animate-spin", strokeWidth: 1 })) : (_jsx(AbortIcon, { className: "h-3.5 w-3.5", strokeWidth: 1 })), label: "Abort translation batch", disabled: !canAbort || isAborting }))] }));
884
903
  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) => {
885
904
  if (e.key === "Enter" || e.key === " ") {
886
905
  e.preventDefault();
887
906
  toggleBatch(b.id);
888
907
  }
889
- }, 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, { counts: statusCounts, testId: `batch-progress-${b.id}` })), _jsxs("div", { className: `flex min-w-0 flex-1 ${isCompactBatches
908
+ }, 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, { counts: statusCounts, status: info?.status, testId: `batch-progress-${b.id}` })), _jsxs("div", { className: `flex min-w-0 flex-1 ${isCompactBatches
890
909
  ? "flex-col gap-2"
891
910
  : "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
892
911
  ? "whitespace-normal break-words leading-5"
893
912
  : "truncate"}`, style: isCompactBatches
894
913
  ? { overflowWrap: "anywhere" }
895
- : 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, { counts: statusCounts, 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, abortingJobIds: abortingJobIds, retryingKeys: retryingKeys, isMobile: isMobile, onToggleItem: toggleItem, onOpenItem: openItemInEditor, onAbortJob: handleAbortJob, onRetryJobs: handleRetryJobs })] }))] }, b.id));
914
+ : 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, { counts: statusCounts, status: info?.status, 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, abortingJobIds: abortingJobIds, retryingKeys: retryingKeys, isMobile: isMobile, onToggleItem: toggleItem, onOpenItem: openItemInEditor, onAbortJob: handleAbortJob, onRetryJobs: handleRetryJobs })] }))] }, b.id));
896
915
  }) })] }, 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"] })) }) }))] })) }) })] }));
897
916
  }
898
917
  export const TRANSLATION_BATCH_FILTERS_SIDEBAR_ID = "translation-batch-filters";
@@ -903,12 +922,16 @@ export function TranslationBatchFiltersSidebar() {
903
922
  const [sitecoreLanguages, setSitecoreLanguages] = useState([]);
904
923
  const [itemPickerOpen, setItemPickerOpen] = useState(false);
905
924
  const currentUserName = editContext?.user?.name || "all";
906
- const isLimitedPreviewUser = editContext?.user?.isLimitedPreviewUser ?? false;
925
+ // Limited-preview sessions and accounts confined to their own batches (see
926
+ // userInfo.canViewAllTranslationBatches) get the user filter pinned to themselves;
927
+ // the server enforces the same scope on every batch endpoint.
928
+ const userFilterLocked = (editContext?.user?.isLimitedPreviewUser ?? false) ||
929
+ editContext?.userInfo?.canViewAllTranslationBatches === false;
907
930
  useEffect(() => {
908
931
  ensureDefaultTranslationBatchUser(currentUserName);
909
932
  }, [currentUserName]);
910
933
  useEffect(() => {
911
- if (isLimitedPreviewUser &&
934
+ if (userFilterLocked &&
912
935
  currentUserName !== "all" &&
913
936
  filters.user !== currentUserName) {
914
937
  setTranslationBatchFilters((prev) => ({
@@ -916,7 +939,7 @@ export function TranslationBatchFiltersSidebar() {
916
939
  user: currentUserName,
917
940
  }));
918
941
  }
919
- }, [isLimitedPreviewUser, currentUserName, filters.user]);
942
+ }, [userFilterLocked, currentUserName, filters.user]);
920
943
  const searchTranslationUsers = useCallback((query, limit) => searchBackendUsers(query, limit), []);
921
944
  const debouncedSetItemFilter = useDebouncedCallback((value) => setTranslationBatchFilters((prev) => ({
922
945
  ...prev,
@@ -993,20 +1016,20 @@ export function TranslationBatchFiltersSidebar() {
993
1016
  })), 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) => ({
994
1017
  ...prev,
995
1018
  status: value,
996
- })), placeholder: "Select status" })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "User" }), _jsx(UserPicker, { value: isLimitedPreviewUser
1019
+ })), placeholder: "Select status" })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "User" }), _jsx(UserPicker, { value: userFilterLocked
997
1020
  ? currentUserName
998
1021
  : filters.user === "all"
999
1022
  ? null
1000
- : filters.user, displayValue: isLimitedPreviewUser || filters.user === currentUserName
1023
+ : filters.user, displayValue: userFilterLocked || filters.user === currentUserName
1001
1024
  ? `${editContext?.user?.fullName?.trim() || currentUserName} (You)`
1002
- : undefined, displayAvatarName: isLimitedPreviewUser || filters.user === currentUserName
1025
+ : undefined, displayAvatarName: userFilterLocked || filters.user === currentUserName
1003
1026
  ? editContext?.user?.fullName?.trim() || currentUserName
1004
- : undefined, displayAvatarUrl: isLimitedPreviewUser || filters.user === currentUserName
1027
+ : undefined, displayAvatarUrl: userFilterLocked || filters.user === currentUserName
1005
1028
  ? editContext?.user?.avatarUrl
1006
1029
  : undefined, onChange: (user) => setTranslationBatchFilters((prev) => ({
1007
1030
  ...prev,
1008
1031
  user: user?.userName ?? "all",
1009
- })), disabled: isLimitedPreviewUser, placeholder: "All Users", buttonClassName: "h-8 min-h-0 w-full justify-start rounded-[4px] border bg-white px-2 py-0 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: [
1032
+ })), disabled: userFilterLocked, placeholder: "All Users", buttonClassName: "h-8 min-h-0 w-full justify-start rounded-[4px] border bg-white px-2 py-0 text-xs font-medium", allowClear: true, clearLabel: "All Users", showIcon: userFilterLocked || filters.user !== "all", searchUsers: searchTranslationUsers })] }), _jsxs("div", { className: filterFieldClass, children: [_jsx("label", { className: filterLabelClass, children: "Provider" }), _jsx(Select, { className: "w-full bg-white", options: [
1010
1033
  { value: "all", label: "All Providers" },
1011
1034
  ...providers.map((p) => ({
1012
1035
  value: p.name,
@@ -1206,22 +1229,66 @@ function computeBatchStatusCounts(info, jobs) {
1206
1229
  const pending = info?.status === "Aborted" ? 0 : remaining;
1207
1230
  return { total, succeeded, failed, aborted, running: 0, pending };
1208
1231
  }
1209
- function BatchProgressIndicator({ counts, testId, variant = "circle", }) {
1210
- const { total, succeeded, failed, aborted, running } = counts;
1232
+ // One outcome per batch, shared by the ring, the bar and the status badge so
1233
+ // the three never disagree about what colour/state a batch is in.
1234
+ function resolveBatchOutcome(status, counts) {
1235
+ const { total, succeeded, failed } = counts;
1236
+ if (isActiveBatchStatus(status))
1237
+ return "active";
1238
+ if (total > 0 && succeeded === total)
1239
+ return "succeeded";
1240
+ if (total > 0 && failed === total)
1241
+ return "failed";
1242
+ if (status === "Aborted")
1243
+ return "aborted";
1244
+ return "mixed";
1245
+ }
1246
+ function BatchProgressIndicator({ counts, status, testId, variant = "circle", }) {
1247
+ const { total, succeeded, failed, aborted, running, pending } = counts;
1248
+ const outcome = resolveBatchOutcome(status, counts);
1249
+ const isActive = outcome === "active";
1250
+ const processed = succeeded + failed + aborted;
1251
+ // SVG filter ids are document-global; derive a safe one per instance.
1252
+ const glowId = `batch-glow-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
1253
+ // While the batch is running the number is "how far along are we", so it
1254
+ // counts every finished job. Once terminal it is the success rate.
1255
+ const percentBase = isActive ? processed : succeeded;
1211
1256
  const percent = total > 0
1212
- ? Math.max(0, Math.min(100, Math.round((succeeded / total) * 100)))
1257
+ ? Math.max(0, Math.min(100, Math.round((percentBase / total) * 100)))
1213
1258
  : 0;
1214
- const allSucceeded = total > 0 && succeeded === total;
1215
- // Colour-carrying segments in tooltip order; pending stays in the track.
1259
+ // Colour-carrying segments in tooltip order; queued work stays in the
1260
+ // track. "Still running" is conveyed by the soft halo around the ring (and
1261
+ // bar) rather than by a segment, so the ring stays calm and readable.
1216
1262
  const segments = [
1217
1263
  { key: "succeeded", count: succeeded, color: BATCH_SEGMENT_COLORS.succeeded },
1218
1264
  { key: "failed", count: failed, color: BATCH_SEGMENT_COLORS.failed },
1219
1265
  { key: "aborted", count: aborted, color: BATCH_SEGMENT_COLORS.aborted },
1220
1266
  { key: "running", count: running, color: BATCH_SEGMENT_COLORS.running },
1221
1267
  ].filter((seg) => seg.count > 0);
1222
- // The centre number is the success rate, so it always reads in the success
1223
- // colour; failed/aborted/running are conveyed by the ring segments instead.
1224
- const textToneClass = "text-feedback-green";
1268
+ // Breathing halo shown only while the batch is active.
1269
+ const glowShadow = "0 0 6px 1px color-mix(in srgb, var(--color-feedback-green) 45%, transparent)";
1270
+ // The centre glyph/number follows the batch outcome so it never contradicts
1271
+ // the ring: green only when everything succeeded, red only when everything
1272
+ // failed, orange when aborted, neutral for running and mixed results.
1273
+ const centreFill = outcome === "succeeded"
1274
+ ? "var(--color-feedback-green-light)"
1275
+ : outcome === "failed"
1276
+ ? "var(--color-feedback-red-light)"
1277
+ : outcome === "aborted"
1278
+ ? "var(--color-feedback-orange-light)"
1279
+ : null;
1280
+ const renderCentreGlyph = (sizeClass) => {
1281
+ if (outcome === "succeeded") {
1282
+ return (_jsx(CheckIcon, { className: `text-feedback-green ${sizeClass}`, style: { strokeWidth: 3 }, "aria-hidden": "true" }));
1283
+ }
1284
+ if (outcome === "failed") {
1285
+ return (_jsx(AlertCircleIcon, { className: `text-feedback-red ${sizeClass}`, style: { strokeWidth: 2.5 }, "aria-hidden": "true" }));
1286
+ }
1287
+ if (outcome === "aborted") {
1288
+ return (_jsx(AbortIcon, { className: `text-feedback-orange ${sizeClass}`, style: { strokeWidth: 2.5 }, "aria-hidden": "true" }));
1289
+ }
1290
+ return null;
1291
+ };
1225
1292
  const parts = [`${succeeded}/${total} succeeded`];
1226
1293
  if (failed > 0)
1227
1294
  parts.push(`${failed} failed`);
@@ -1229,20 +1296,24 @@ function BatchProgressIndicator({ counts, testId, variant = "circle", }) {
1229
1296
  parts.push(`${aborted} aborted`);
1230
1297
  if (running > 0)
1231
1298
  parts.push(`${running} running`);
1299
+ if (isActive && pending > 0)
1300
+ parts.push(`${pending} queued`);
1232
1301
  const label = parts.join(", ");
1233
1302
  if (variant === "bar") {
1234
- 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: "bg-neutral-grey-15 flex h-1.5 min-w-0 flex-1 overflow-hidden rounded-full", children: total > 0 &&
1235
- segments.map((seg) => (_jsx("div", { className: "h-full", style: {
1236
- width: `${(seg.count / total) * 100}%`,
1237
- backgroundColor: seg.color,
1238
- } }, seg.key))) }), allSucceeded ? (_jsx(CheckIcon, { className: "text-feedback-green h-3 w-3 shrink-0", style: { strokeWidth: 3 } })) : (_jsxs("span", { className: `shrink-0 text-[11px] leading-none font-medium tabular-nums ${textToneClass}`, children: [percent, "%"] }))] }) }), _jsx(TooltipContent, { side: "top", sideOffset: 6, children: label })] }));
1303
+ const glyph = renderCentreGlyph("h-3 w-3 shrink-0");
1304
+ 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, "data-outcome": outcome, children: [_jsxs("div", { className: "relative min-w-0 flex-1", children: [isActive && (_jsx("span", { "aria-hidden": "true", className: "pointer-events-none absolute -inset-px rounded-full animate-pulse", style: { boxShadow: glowShadow } })), _jsx("div", { className: "bg-neutral-grey-15 flex h-1.5 min-w-0 overflow-hidden rounded-full", children: total > 0 &&
1305
+ segments.map((seg) => (_jsx("div", { className: "h-full", style: {
1306
+ width: `${(seg.count / total) * 100}%`,
1307
+ backgroundColor: seg.color,
1308
+ } }, seg.key))) })] }), glyph ?? (_jsxs("span", { className: "shrink-0 text-[11px] leading-none font-medium tabular-nums text-neutral-grey-100", children: [percent, "%"] }))] }) }), _jsx(TooltipContent, { side: "top", sideOffset: 6, children: label })] }));
1239
1309
  }
1240
1310
  const radius = 16;
1241
1311
  const strokeWidth = 3;
1242
1312
  const circumference = 2 * Math.PI * radius;
1243
1313
  const gap = segments.length > 1 ? circumference * 0.02 : 0;
1244
1314
  let segStart = 0;
1245
- return (_jsxs(Tooltip, { delayDuration: 250, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs("div", { className: "relative h-10 w-10 shrink-0 rounded-full", "aria-label": label, "data-testid": testId, children: [_jsxs("svg", { className: "h-10 w-10 -rotate-90", viewBox: "0 0 40 40", children: [allSucceeded && (_jsx("circle", { cx: "20", cy: "20", r: radius - strokeWidth / 2, fill: "var(--color-feedback-green-light)" })), _jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", stroke: "var(--color-neutral-grey-15)", strokeWidth: strokeWidth }), total > 0 &&
1315
+ const glyph = renderCentreGlyph("h-4 w-4");
1316
+ return (_jsxs(Tooltip, { delayDuration: 250, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs("div", { className: "relative h-10 w-10 shrink-0 rounded-full", "aria-label": label, "data-testid": testId, "data-outcome": outcome, children: [_jsxs("svg", { className: "h-10 w-10 -rotate-90 overflow-visible", viewBox: "0 0 40 40", children: [isActive && (_jsxs(_Fragment, { children: [_jsx("defs", { children: _jsx("filter", { id: glowId, x: "-50%", y: "-50%", width: "200%", height: "200%", children: _jsx("feGaussianBlur", { stdDeviation: "2" }) }) }), _jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", stroke: "var(--color-feedback-green)", strokeWidth: strokeWidth + 4, filter: `url(#${glowId})`, opacity: 0.35, children: _jsx("animate", { attributeName: "opacity", values: "0.15;0.5;0.15", dur: "1.8s", repeatCount: "indefinite" }) })] })), centreFill && (_jsx("circle", { cx: "20", cy: "20", r: radius - strokeWidth / 2, fill: centreFill })), _jsx("circle", { cx: "20", cy: "20", r: radius, fill: "none", stroke: "var(--color-neutral-grey-15)", strokeWidth: strokeWidth }), total > 0 &&
1246
1317
  segments.map((seg) => {
1247
1318
  const segLen = (seg.count / total) * circumference;
1248
1319
  const dash = Math.max(0, segLen - gap);
@@ -1252,7 +1323,7 @@ function BatchProgressIndicator({ counts, testId, variant = "circle", }) {
1252
1323
  strokeDasharray: `${dash} ${circumference}`,
1253
1324
  strokeDashoffset: dashOffset,
1254
1325
  } }, seg.key));
1255
- })] }), _jsx("span", { className: "absolute inset-0 flex items-center justify-center", children: allSucceeded ? (_jsx(CheckIcon, { className: "text-feedback-green h-4 w-4", style: { strokeWidth: 3 } })) : (_jsxs("span", { className: `text-[10px] leading-none font-medium tabular-nums ${textToneClass}`, children: [percent, "%"] })) })] }) }), _jsx(TooltipContent, { side: "top", sideOffset: 6, children: label })] }));
1326
+ })] }), _jsx("span", { className: "absolute inset-0 flex items-center justify-center", children: glyph ?? (_jsxs("span", { className: "text-[10px] leading-none font-medium tabular-nums text-neutral-grey-100", children: [percent, "%"] })) })] }) }), _jsx(TooltipContent, { side: "top", sideOffset: 6, children: label })] }));
1256
1327
  }
1257
1328
  function itemStatusDot(jobs) {
1258
1329
  const hasError = jobs.some((j) => j.status === "Error");
@@ -1269,7 +1340,44 @@ function itemStatusDot(jobs) {
1269
1340
  return { cls: "bg-feedback-orange", label: "Aborted" };
1270
1341
  return { cls: "bg-feedback-green/70", label: "Completed" };
1271
1342
  }
1272
- function InlineLanguageJobChips({ batchId, batchProvider, jobs, languageMap, isExpanded, batchStartedAtUtc, abortingJobIds, retryingKeys, singleLine = false, onToggle, onOpenItem, onAbortJob, onRetryJobs, }) {
1343
+ // Fallback one-row cap used until the first chip row has been measured.
1344
+ const COLLAPSED_CHIP_ROW_HEIGHT_PX = 28;
1345
+ function InlineLanguageJobChips({ batchId, batchProvider, jobs, languageMap, isExpanded, batchStartedAtUtc, abortingJobIds, retryingKeys, onToggle, onOpenItem, onAbortJob, onRetryJobs, }) {
1346
+ const chipAreaRef = React.useRef(null);
1347
+ const [hiddenCount, setHiddenCount] = React.useState(0);
1348
+ const [firstRowHeight, setFirstRowHeight] = React.useState(null);
1349
+ // Chips always wrap so they can never run into the neighbouring column.
1350
+ // When the item is collapsed only the first line is shown; count the chips
1351
+ // that wrapped onto later lines so a "+N" chip can stand in for them.
1352
+ React.useLayoutEffect(() => {
1353
+ const el = chipAreaRef.current;
1354
+ if (!el || isExpanded) {
1355
+ setHiddenCount(0);
1356
+ return;
1357
+ }
1358
+ const measure = () => {
1359
+ const children = Array.from(el.children);
1360
+ if (children.length === 0) {
1361
+ setHiddenCount(0);
1362
+ return;
1363
+ }
1364
+ const firstTop = children[0]?.offsetTop ?? 0;
1365
+ let hidden = 0;
1366
+ for (const child of children) {
1367
+ if (child.offsetTop > firstTop)
1368
+ hidden += 1;
1369
+ }
1370
+ setHiddenCount(hidden);
1371
+ setFirstRowHeight(children[0]?.offsetHeight ?? null);
1372
+ };
1373
+ measure();
1374
+ let observer;
1375
+ if (typeof ResizeObserver !== "undefined") {
1376
+ observer = new ResizeObserver(measure);
1377
+ observer.observe(el);
1378
+ }
1379
+ return () => observer?.disconnect();
1380
+ }, [isExpanded, jobs.length]);
1273
1381
  if (jobs.length === 0)
1274
1382
  return null;
1275
1383
  const handleCellClick = (event) => {
@@ -1283,15 +1391,20 @@ function InlineLanguageJobChips({ batchId, batchProvider, jobs, languageMap, isE
1283
1391
  onToggle();
1284
1392
  }
1285
1393
  };
1286
- return (_jsx("div", { role: "button", tabIndex: 0, className: `flex min-w-0 cursor-pointer items-start justify-start gap-1.5 ${singleLine
1287
- ? "flex-nowrap"
1288
- : isExpanded
1289
- ? "flex-wrap"
1290
- : "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) => {
1291
- const lang = languageMap.get((job.targetLanguage || "").toLowerCase());
1292
- const retryKey = `job:${job.id ?? `${job.itemId}:${job.sourceLanguage}:${job.targetLanguage}`}`;
1293
- return (_jsx("span", { className: "shrink-0", onClick: (event) => event.stopPropagation(), children: _jsx(LanguageJobChip, { job: job, language: lang, provider: batchProvider, batchStartedAtUtc: batchStartedAtUtc, onOpen: () => onOpenItem(job.itemId, job.targetLanguage), isAborting: !!job.id && abortingJobIds.has(job.id), onAbort: () => onAbortJob(job), isRetrying: retryingKeys.has(retryKey), onRetry: () => onRetryJobs(batchId, batchProvider, [job], retryKey) }) }, `${job.id ?? idx}-${job.targetLanguage}`));
1294
- }) }));
1394
+ return (_jsxs("div", { role: "button", tabIndex: 0, className: "flex cursor-pointer items-start gap-1.5", "aria-label": `${jobs.length} target language${jobs.length !== 1 ? "s" : ""}`, "aria-expanded": isExpanded, onClick: handleCellClick, onKeyDown: handleCellKeyDown, children: [_jsx("div", { ref: chipAreaRef, className: `flex flex-1 flex-wrap items-start gap-1.5 ${isExpanded ? "" : "overflow-hidden"}`,
1395
+ // The cap is the measured height of one chip row, applied inline so
1396
+ // the clip does not depend on a Tailwind max-height utility being
1397
+ // emitted for this file.
1398
+ style: isExpanded
1399
+ ? undefined
1400
+ : { maxHeight: firstRowHeight ?? COLLAPSED_CHIP_ROW_HEIGHT_PX }, children: jobs.map((job, idx) => {
1401
+ const lang = languageMap.get((job.targetLanguage || "").toLowerCase());
1402
+ const retryKey = `job:${job.id ?? `${job.itemId}:${job.sourceLanguage}:${job.targetLanguage}`}`;
1403
+ return (_jsx("span", { className: "shrink-0", onClick: (event) => event.stopPropagation(), children: _jsx(LanguageJobChip, { job: job, language: lang, provider: batchProvider, batchStartedAtUtc: batchStartedAtUtc, onOpen: () => onOpenItem(job.itemId, job.targetLanguage), isAborting: !!job.id && abortingJobIds.has(job.id), onAbort: () => onAbortJob(job), isRetrying: retryingKeys.has(retryKey), onRetry: () => onRetryJobs(batchId, batchProvider, [job], retryKey) }) }, `${job.id ?? idx}-${job.targetLanguage}`));
1404
+ }) }), !isExpanded && hiddenCount > 0 && (_jsxs("button", { type: "button", className: "mt-px shrink-0 rounded border border-border-default bg-neutral-grey-5 badge-pad-sm text-[12px] leading-none tabular-nums text-neutral-grey-50 transition-colors hover:bg-neutral-grey-15 hover:text-neutral-grey-100", "aria-label": `Show ${hiddenCount} more language${hiddenCount !== 1 ? "s" : ""}`, title: `${hiddenCount} more language${hiddenCount !== 1 ? "s" : ""}`, onClick: (event) => {
1405
+ event.stopPropagation();
1406
+ onToggle();
1407
+ }, children: ["+", hiddenCount] }))] }));
1295
1408
  }
1296
1409
  function CustomPromptPanel({ prompts }) {
1297
1410
  const [openProvider, setOpenProvider] = React.useState(null);
@@ -1431,7 +1544,10 @@ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems,
1431
1544
  }
1432
1545
  if (stackLanguageChips) {
1433
1546
  const normalMinWidth = normalTableMinWidthRef.current;
1434
- if (normalMinWidth && availableWidth >= normalMinWidth) {
1547
+ // Unstack once the columns layout fits again. Before that layout has
1548
+ // ever been measured, fall back to the threshold that forced stacking
1549
+ // so a narrow-then-wide resize does not stay stacked forever.
1550
+ if (availableWidth >= (normalMinWidth ?? 640)) {
1435
1551
  setStackLanguageChips(false);
1436
1552
  }
1437
1553
  return;
@@ -1476,7 +1592,7 @@ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems,
1476
1592
  byItem.set(key, []);
1477
1593
  byItem.get(key).push(job);
1478
1594
  }
1479
- 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) => {
1595
+ 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: "w-12 pt-3 pr-3 pb-1 md:pr-4" }))] }) }), _jsx("tbody", { children: Array.from(byItem.entries()).map(([itemId, itemJobs], itemIndex) => {
1480
1596
  const isItemExpanded = expandedItems.has(itemId);
1481
1597
  const itemAnyInProgress = itemJobs.some((j) => j.status === "In Progress");
1482
1598
  const itemAnyPending = itemJobs.some((j) => j.status === "Pending");
@@ -1496,7 +1612,7 @@ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems,
1496
1612
  .filter((language) => Boolean(language));
1497
1613
  const normalizedCurrentLanguage = normalizeLanguageCode(currentLanguage);
1498
1614
  const rowOpenLanguage = rowLanguages.find((language) => normalizeLanguageCode(language) === normalizedCurrentLanguage) ?? rowLanguages[0];
1499
- const languageChips = (_jsx(InlineLanguageJobChips, { batchId: batchId, batchProvider: batchProvider, jobs: itemJobs, languageMap: languageMap, isExpanded: isItemExpanded, batchStartedAtUtc: batchStartedAtUtc, abortingJobIds: abortingJobIds, retryingKeys: retryingKeys, singleLine: !stackLanguageChips, onToggle: () => onToggleItem(itemId), onOpenItem: onOpenItem, onAbortJob: onAbortJob, onRetryJobs: onRetryJobs }));
1615
+ const languageChips = (_jsx(InlineLanguageJobChips, { batchId: batchId, batchProvider: batchProvider, jobs: itemJobs, languageMap: languageMap, isExpanded: isItemExpanded, batchStartedAtUtc: batchStartedAtUtc, abortingJobIds: abortingJobIds, retryingKeys: retryingKeys, onToggle: () => onToggleItem(itemId), onOpenItem: onOpenItem, onAbortJob: onAbortJob, onRetryJobs: onRetryJobs }));
1500
1616
  const showRetryAction = itemErrorJobs.length > 0;
1501
1617
  const retryAction = showRetryAction ? (_jsx("div", { className: "flex shrink-0 items-center justify-end", children: _jsx(SimpleIconButton, { onClick: (event) => {
1502
1618
  event.stopPropagation();
@@ -1514,7 +1630,7 @@ function BatchItemList({ batchId, batchProvider, jobs, isLoading, expandedItems,
1514
1630
  e.preventDefault();
1515
1631
  void onOpenItem(itemId, rowOpenLanguage);
1516
1632
  }
1517
- }, 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));
1633
+ }, 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: "w-12 py-2 pr-3 align-top whitespace-nowrap md:pr-4", children: retryAction }))] }, itemId));
1518
1634
  }) })] }) }));
1519
1635
  }
1520
1636
  function LanguageJobChip({ job, language, provider, batchStartedAtUtc, onOpen, isAborting = false, onAbort, isRetrying = false, onRetry, }) {
@@ -1607,10 +1723,10 @@ function LanguageJobChip({ job, language, provider, batchStartedAtUtc, onOpen, i
1607
1723
  const emptyFieldText = statistics && statistics.emptyFieldCount > 0
1608
1724
  ? `${formatCount(statistics.emptyFieldCount)} empty`
1609
1725
  : null;
1610
- 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, "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
1726
+ 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, "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-[26rem] 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
1611
1727
  ? "Approximate — measured from batch start"
1612
1728
  : 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 &&
1613
- (jobError || jobAborted ? (_jsxs("div", { className: "flex flex-col gap-1 px-3 py-2", children: [_jsx("dt", { className: "text-[10px] tracking-wide text-neutral-grey-50", children: jobError ? "Error" : "Aborted" }), _jsx("dd", { className: `max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-sm bg-neutral-grey-5 p-2 font-mono text-[11px] leading-snug ${jobError ? "text-feedback-red" : "text-feedback-orange"}`, children: job.message })] })) : (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Progress" }), _jsx("dd", { className: "text-[12px] break-words text-neutral-grey-100", children: job.message })] }))), job.batchId && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Batch" }), _jsx("dd", { className: "text-[11px] font-mono text-neutral-grey-50 break-all", children: job.batchId })] }))] }), _jsxs("div", { className: "flex justify-end gap-2 border-t border-border-default px-3 py-2", children: [canInspectRawResponse && (_jsx(ContentInspectorPopover, { title: "Raw AI response", emptyMessage: "Nothing captured for this job yet. Captures are kept in memory only and exist for AI provider jobs that ran since the last restart.", downloadFileName: `translation-job-${job.id}-raw-response.txt`, className: "w-[min(64rem,calc(100vw-2rem))]", side: "left", align: "start", loadSections: async () => {
1729
+ (jobError || jobAborted ? (_jsxs("div", { className: "flex flex-col gap-1 px-3 py-2", children: [_jsx("dt", { className: "text-[10px] tracking-wide text-neutral-grey-50", children: jobError ? "Error" : "Aborted" }), _jsx("dd", { className: `max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-sm bg-neutral-grey-5 p-2 font-mono text-[11px] leading-snug ${jobError ? "text-feedback-red" : "text-feedback-orange"}`, children: job.message })] })) : (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Progress" }), _jsx("dd", { className: "text-[12px] break-words text-neutral-grey-100", children: job.message })] }))), job.batchId && (_jsxs("div", { className: "flex items-baseline gap-3 px-3 py-2", children: [_jsx("dt", { className: "w-20 shrink-0 text-[10px] tracking-wide text-neutral-grey-50", children: "Batch" }), _jsx("dd", { className: "text-[11px] font-mono text-neutral-grey-50 break-all", children: job.batchId })] }))] }), _jsxs("div", { className: "flex flex-wrap items-center justify-end gap-2 border-t border-border-default px-3 py-2", children: [canInspectRawResponse && (_jsx(ContentInspectorPopover, { title: "Raw AI response", emptyMessage: "Nothing captured for this job yet. Captures are kept in memory only and exist for AI provider jobs that ran since the last restart.", downloadFileName: `translation-job-${job.id}-raw-response.txt`, className: "w-[min(64rem,calc(100vw-2rem))]", side: "bottom", align: "end", loadSections: async () => {
1614
1730
  const { payload, rawResponse } = await getTranslationJobLastPayload(job.id);
1615
1731
  const sections = [];
1616
1732
  if (rawResponse) {
@@ -1620,14 +1736,17 @@ function LanguageJobChip({ job, language, provider, batchStartedAtUtc, onOpen, i
1620
1736
  });
1621
1737
  }
1622
1738
  if (payload) {
1623
- sections.push({ title: "Last request payload", content: payload });
1739
+ sections.push({
1740
+ title: "Last request payload",
1741
+ content: payload,
1742
+ });
1624
1743
  }
1625
1744
  return sections;
1626
1745
  }, trigger: _jsxs(Button, { size: "sm", variant: "outline", "data-testid": "translation-job-raw-response-button", onClick: (e) => e.stopPropagation(), className: "mr-auto gap-1.5 text-neutral-grey-50", title: "Show the raw AI request and responses for this job", children: [_jsx(BugIcon, { className: "h-3.5 w-3.5" }), "Raw response"] }) })), canAbort && (_jsxs(Button, { size: "sm", variant: "outline", disabled: isAborting, onClick: (e) => {
1627
1746
  e.stopPropagation();
1628
1747
  void onAbort?.();
1629
1748
  setOpen(false);
1630
- }, className: "gap-1.5 text-feedback-orange hover:text-feedback-orange", children: [_jsx(AbortIcon, { className: `h-3.5 w-3.5 ${isAborting ? "animate-spin" : ""}` }), "Abort"] })), jobError && onRetry && (_jsxs(Button, { size: "sm", variant: "outline", disabled: isRetrying, onClick: (e) => {
1749
+ }, className: "gap-1.5 text-neutral-grey-50 hover:text-neutral-grey-100", children: [isAborting ? (_jsx(LoaderIcon, { className: "h-3.5 w-3.5 animate-spin" })) : (_jsx(AbortIcon, { className: "h-3.5 w-3.5" })), "Abort"] })), jobError && onRetry && (_jsxs(Button, { size: "sm", variant: "outline", disabled: isRetrying, onClick: (e) => {
1631
1750
  e.stopPropagation();
1632
1751
  void onRetry();
1633
1752
  setOpen(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parhelia/localization",
3
- "version": "0.4.12796",
3
+ "version": "0.4.12818",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"