diff --git a/frontend/index.html b/frontend/index.html index a1e2c71ed..0de76cc68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -115,32 +115,95 @@ } @@ -148,7 +211,7 @@
-

Starting Windshift…

+

Starting Windshift…

diff --git a/frontend/package.json b/frontend/package.json index 680a0e98f..d445ba1bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "format:check": "biome format .", "lint": "biome lint .", "lint:fix": "biome lint --write .", - "check": "biome check . && node scripts/check-shortcuts.js && node scripts/check-i18n.js", + "check": "biome check . && node scripts/check-shortcuts.js && node scripts/check-i18n.js && node scripts/check-hardcoded-i18n.js", "check:entry-assets": "node scripts/check-entry-assets.js", "check:fix": "biome check --write .", "test": "bun --bun vitest", diff --git a/frontend/scripts/check-hardcoded-i18n.js b/frontend/scripts/check-hardcoded-i18n.js new file mode 100644 index 000000000..02a8077bd --- /dev/null +++ b/frontend/scripts/check-hardcoded-i18n.js @@ -0,0 +1,107 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// This list is an incremental ratchet: once a user-facing screen has been +// migrated, literal English UI copy must not return to it. +const guardedFiles = [ + 'src/lib/layout/DashboardCustomizationSidebar.svelte', + 'src/lib/pages/Homepage.svelte', + 'src/lib/features/workflows/WorkflowBuilder.svelte', + 'src/lib/pages/Screens.svelte', + 'src/lib/pickers/ConfigurationSetPicker.svelte', + 'src/lib/pickers/ScreenPicker.svelte', + 'src/lib/pickers/WorkflowPicker.svelte', + 'src/lib/settings/ConfigurationSetManager.svelte', + 'src/lib/settings/ConfigurationSetItemTypes.svelte', + 'src/lib/settings/ActionCapabilitiesManager.svelte', + 'src/lib/settings/ActionCredentialManager.svelte', + 'src/lib/settings/AgentTemplateManager.svelte', + 'src/lib/settings/CapabilityManager.svelte', + 'src/lib/settings/HierarchyLevelManager.svelte', + 'src/lib/settings/ItemTypeManager.svelte', + 'src/lib/settings/IntegrationProviderManager.svelte', + 'src/lib/settings/IntegrationsManager.svelte', + 'src/lib/settings/LLMConnectionManager.svelte', + 'src/lib/settings/LinkTypeManager.svelte', + 'src/lib/settings/OAuthClientManager.svelte', + 'src/lib/settings/PriorityManager.svelte', + 'src/lib/settings/StatusCategoryManager.svelte', + 'src/lib/settings/StatusManager.svelte', + 'src/lib/settings/ThemeManager.svelte', + 'src/lib/settings/RunnerPoolManager.svelte', + 'src/lib/workspaces/WorkspaceConfigurationAssigner.svelte', + 'src/lib/workspaces/WorkspaceConfigurationPreview.svelte', + 'src/lib/workspaces/WorkspaceMembers.svelte', + 'src/lib/workspaces/WorkspaceCustomizationSidebar.svelte', + 'src/lib/workspaces/WorkspaceNavigation.svelte', + 'src/lib/workspaces/WorkspaceWelcome.svelte', + 'src/lib/workspaces/Workspaces.svelte', + 'src/lib/widgets/CompletionChartWidget.svelte', + 'src/lib/widgets/CreatedChartWidget.svelte', + 'src/lib/widgets/IterationTimelineWidget.svelte', + 'src/lib/widgets/MilestoneProgressWidget.svelte', + 'src/lib/widgets/MyTasksWidget.svelte', + 'src/lib/widgets/OverdueItemsWidget.svelte', + 'src/lib/widgets/RecentItemsWidget.svelte', + 'src/lib/widgets/StatsCardWidget.svelte', + 'src/lib/widgets/TestCoverageWidget.svelte', + 'src/lib/widgets/UpcomingDeadlinesWidget.svelte', + 'src/lib/widgets/WidgetState.svelte', + 'src/lib/widgets/WidgetWrapper.svelte', + 'src/lib/widgets/dashboard/AssignedToMeWidget.svelte', + 'src/lib/widgets/dashboard/DailyBriefingWidget.svelte', + 'src/lib/widgets/dashboard/DashboardItemRow.svelte', + 'src/lib/widgets/dashboard/DashboardTaskList.svelte', + 'src/lib/widgets/dashboard/DueMark.svelte', + 'src/lib/widgets/dashboard/PersonalTasksWidget.svelte', + 'src/lib/widgets/dashboard/QuickAccessWidget.svelte', + 'src/lib/widgets/dashboard/RecentWorkspacesWidget.svelte', + 'src/lib/widgets/dashboard/SavedSearchWidget.svelte', + 'src/lib/widgets/dashboard/UpcomingMilestonesWidget.svelte', + 'src/lib/widgets/dashboard/WatchedItemsWidget.svelte', + 'src/lib/widgets/dashboard/WhatsNewWidget.svelte', + 'src/lib/widgets/dashboard/YourActivityWidget.svelte', +]; + +const rules = [ + { + name: 'visible text', + pattern: />\s*([A-Z][^<{\n]*?)\s*` that look like HTML text to + // the lightweight regex guard. UI copy lives in the component markup. + const source = readFileSync(path.join(root, relativeFile), 'utf8').replace( + /]*>[\s\S]*?<\/script>/g, + (script) => '\n'.repeat(script.split('\n').length - 1) + ); + for (const rule of rules) { + for (const match of source.matchAll(rule.pattern)) { + if (intentionalLiterals.has(match[1].trim())) continue; + const line = source.slice(0, match.index).split('\n').length; + violations.push(`${relativeFile}:${line} ${rule.name}: ${JSON.stringify(match[1].trim())}`); + } + } +} + +if (violations.length > 0) { + console.error('Hardcoded i18n guard failed. Move this copy into the locale catalog:'); + for (const violation of violations) console.error(` ${violation}`); + process.exit(1); +} + +console.log(`Hardcoded i18n guard passed (${guardedFiles.length} migrated screens).`); diff --git a/frontend/scripts/check-i18n.js b/frontend/scripts/check-i18n.js index ebe704855..ab892a657 100644 --- a/frontend/scripts/check-i18n.js +++ b/frontend/scripts/check-i18n.js @@ -4,7 +4,8 @@ * i18n Validation Script * * Validates locale files against English (reference locale) and detects: - * - Missing or extra keys in non-English locales + * - Missing-key coverage and invalid extra keys in non-English locales + * (missing keys use the application's English runtime fallback) * - Source keys referenced in code but missing from English catalog * - Placeholder mismatches between English and other locales * - Untranslated English carryovers in non-English locales @@ -15,12 +16,18 @@ import { glob, readdir, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ENGLISH_FALLBACK_KEYS } from '../src/lib/locales/adminOperationsFallback.js'; import { mergeInto } from '../src/lib/locales/createLocale.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const LOCALES_DIR = join(__dirname, '..', 'src', 'lib', 'locales'); const SRC_DIR = join(__dirname, '..', 'src'); const REFERENCE_LOCALE = 'en'; +// Russian is shipped as a fully reviewed locale and must never silently fall +// back to English. Older locales may remain partially translated while still +// using the application's documented English runtime fallback. +const REQUIRED_FULL_COVERAGE_LOCALES = new Set(['ru']); +const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; // These values intentionally retain product names, code syntax, URLs, or sample identifiers. const INTENTIONAL_CARRYOVERS = new Set([ @@ -87,6 +94,65 @@ function extractPlaceholders(str) { return matches ? matches.map((m) => m.slice(1, -1)).sort() : []; } +function splitPluralKey(key) { + const match = key.match(PLURAL_SUFFIX_PATTERN); + if (!match) return null; + + return { + baseKey: key.slice(0, -match[0].length), + category: match[1], + }; +} + +function getPluralCategories(localeCode) { + return new Set(new Intl.PluralRules(localeCode).resolvedOptions().pluralCategories); +} + +function collectPluralBases(keys) { + const categoriesByBase = new Map(); + + for (const key of keys) { + const plural = splitPluralKey(key); + if (!plural) continue; + + const categories = categoriesByBase.get(plural.baseKey) ?? new Set(); + categories.add(plural.category); + categoriesByBase.set(plural.baseKey, categories); + } + + return new Set( + [...categoriesByBase] + .filter(([, categories]) => categories.has('other') && categories.size > 1) + .map(([baseKey]) => baseKey) + ); +} + +function isLocaleSpecificPluralKey(key, pluralBases, localePluralCategories) { + const plural = splitPluralKey(key); + return ( + plural !== null && + pluralBases.has(plural.baseKey) && + localePluralCategories.has(plural.category) + ); +} + +function findReferenceEntry(key, refEntries, pluralBases, localePluralCategories) { + if (Object.hasOwn(refEntries, key)) { + return { key, value: refEntries[key] }; + } + + if (!isLocaleSpecificPluralKey(key, pluralBases, localePluralCategories)) { + return null; + } + + const { baseKey } = splitPluralKey(key); + const fallbackKey = [`${baseKey}_other`, `${baseKey}_one`].find((candidate) => + Object.hasOwn(refEntries, candidate) + ); + + return fallbackKey ? { key: fallbackKey, value: refEntries[fallbackKey] } : null; +} + function findSourceFile(key, fileMap) { for (const [filename, keys] of Object.entries(fileMap)) { if (keys.has(key)) return filename; @@ -100,22 +166,39 @@ async function loadLocaleFiles(localeCode) { .filter((f) => f.endsWith('.js') && f !== 'index.js') .sort((a, b) => { const rank = (file) => - file === 'review.js' ? 3 : file === 'quality.js' ? 2 : file === 'supplemental.js' ? 1 : 0; + file === 'review.js' + ? 3 + : file === 'quality.js' + ? 2 + : file === 'supplemental.js' + ? 1 + : file === 'dashboard.js' + ? 0.5 + : 0; return rank(a) - rank(b) || a.localeCompare(b); }); const merged = {}; const fileKeyMap = {}; + const fallbackKeys = new Set(); + const explicitKeys = new Set(); for (const file of files) { const mod = await import(join(localeDir, file)); const data = mod.default || mod; const keys = new Set(flattenKeys(data)); + const fileFallbackKeys = data[ENGLISH_FALLBACK_KEYS] ?? new Set(); fileKeyMap[file] = keys; + for (const key of keys) { + if (fileFallbackKeys.has(key)) fallbackKeys.add(key); + else explicitKeys.add(key); + } mergeInto(merged, data); } - return { merged, fileKeyMap }; + for (const key of explicitKeys) fallbackKeys.delete(key); + + return { merged, fileKeyMap, fallbackKeys }; } async function extractSourceKeys() { @@ -142,17 +225,22 @@ async function extractSourceKeys() { return keys; } -function detectCarryovers(english, other, _localeCode) { +function detectCarryovers(english, other, localeCode, fallbackKeys = new Set()) { const carryovers = []; const enEntries = Object.fromEntries( flattenAll(english).filter(([, v]) => typeof v === 'string') ); + const pluralBases = collectPluralBases(Object.keys(enEntries)); + const localePluralCategories = getPluralCategories(localeCode); for (const [key, value] of flattenAll(other)) { if (INTENTIONAL_CARRYOVERS.has(key)) continue; + if (fallbackKeys.has(key)) continue; if (typeof value !== 'string') continue; - const enValue = enEntries[key]; - if (!enValue) continue; + const reference = findReferenceEntry(key, enEntries, pluralBases, localePluralCategories); + if (!reference) continue; + + const enValue = reference.value; const words = value.split(/\s+/); const wordCount = words.length; @@ -201,6 +289,7 @@ async function main() { const refEntries = Object.fromEntries( flattenAll(ref.merged).filter(([, v]) => typeof v === 'string') ); + const refPluralBases = collectPluralBases(refLeafKeys); console.log(`\n Reference: ${REFERENCE_LOCALE} (${refLeafKeys.size} leaf keys)\n`); @@ -227,26 +316,50 @@ async function main() { console.log(' --- Locale key parity ---'); const otherLocales = localeDirs.filter((l) => l !== REFERENCE_LOCALE).sort(); let totalMissing = 0; + let requiredMissing = 0; let totalExtra = 0; for (const locale of otherLocales) { const loc = await loadLocaleFiles(locale); const locKeys = new Set(flattenKeys(loc.merged)); + const localePluralCategories = getPluralCategories(locale); const missing = [...refLeafKeys].filter((k) => !locKeys.has(k)).sort(); - const extra = [...locKeys].filter((k) => !refLeafKeys.has(k)).sort(); + const localePluralVariants = [...locKeys] + .filter( + (k) => + !refLeafKeys.has(k) && + isLocaleSpecificPluralKey(k, refPluralBases, localePluralCategories) + ) + .sort(); + const extra = [...locKeys] + .filter( + (k) => + !refLeafKeys.has(k) && + !isLocaleSpecificPluralKey(k, refPluralBases, localePluralCategories) + ) + .sort(); totalMissing += missing.length; + if (REQUIRED_FULL_COVERAGE_LOCALES.has(locale)) requiredMissing += missing.length; totalExtra += extra.length; const coverage = (((refLeafKeys.size - missing.length) / refLeafKeys.size) * 100).toFixed(1); if (missing.length === 0 && extra.length === 0) { - console.log(` ✓ ${locale} ${coverage}% coverage (${locKeys.size} keys)`); - } else { + const pluralSuffix = + localePluralVariants.length > 0 + ? `, ${localePluralVariants.length} locale plural variant(s)` + : ''; + console.log(` ✓ ${locale} ${coverage}% coverage (${locKeys.size} keys${pluralSuffix})`); + } else if (extra.length > 0 || REQUIRED_FULL_COVERAGE_LOCALES.has(locale)) { console.log( ` ✗ ${locale} ${coverage}% coverage (${locKeys.size} keys, ${missing.length} missing, ${extra.length} extra)` ); + } else { + console.log( + ` ⚠ ${locale} ${coverage}% coverage (${locKeys.size} keys, ${missing.length} using English fallback)` + ); } if (missing.length > 0 && verbose) { @@ -274,11 +387,14 @@ async function main() { const locEntries = Object.fromEntries( flattenAll(loc.merged).filter(([, v]) => typeof v === 'string') ); + const localePluralCategories = getPluralCategories(locale); const mismatches = []; - for (const [key, enValue] of Object.entries(refEntries)) { - const locValue = locEntries[key]; - if (!locValue) continue; + for (const [key, locValue] of Object.entries(locEntries)) { + const reference = findReferenceEntry(key, refEntries, refPluralBases, localePluralCategories); + if (!reference || !locValue) continue; + + const enValue = reference.value; const enPlaceholders = extractPlaceholders(enValue); const locPlaceholders = extractPlaceholders(locValue); @@ -292,7 +408,14 @@ async function main() { const extra = locPlaceholders.filter((p) => p !== 'plural' && !enSet.has(p)); if (missing.length > 0 || extra.length > 0) { - mismatches.push({ key, enValue, locValue, missing, extra }); + mismatches.push({ + key, + referenceKey: reference.key, + enValue, + locValue, + missing, + extra, + }); } } @@ -304,7 +427,8 @@ async function main() { if (m.extra.length > 0) details.push(`extra: {${m.extra.join('}, {')}}`); console.log(` ${m.key} — ${details.join(', ')}`); if (verbose) { - console.log(` EN: ${m.enValue}`); + const referenceLabel = m.referenceKey === m.key ? 'EN' : `EN (${m.referenceKey})`; + console.log(` ${referenceLabel}: ${m.enValue}`); console.log(` ${locale}: ${m.locValue}`); } } @@ -326,7 +450,7 @@ async function main() { for (const locale of otherLocales) { const loc = await loadLocaleFiles(locale); - const carryovers = detectCarryovers(ref.merged, loc.merged, locale); + const carryovers = detectCarryovers(ref.merged, loc.merged, locale, loc.fallbackKeys); if (carryovers.length > 0) { console.log(` ✗ ${locale}: ${carryovers.length} suspected carryover(s):`); @@ -351,7 +475,7 @@ async function main() { console.log(''); // --- Summary --- - if (totalMissing > 0 || totalExtra > 0) { + if (requiredMissing > 0 || totalExtra > 0) { exitCode = 1; } @@ -361,7 +485,8 @@ async function main() { const issues = []; if (missingFromEn.length > 0) issues.push(`${missingFromEn.length} source key(s) missing from English`); - if (totalMissing > 0) issues.push(`${totalMissing} missing locale key(s)`); + if (totalMissing > 0) issues.push(`${totalMissing} locale key(s) using English fallback`); + if (requiredMissing > 0) issues.push(`${requiredMissing} required locale key(s) missing`); if (totalExtra > 0) issues.push(`${totalExtra} extra locale key(s)`); if (placeholderErrors > 0) issues.push(`${placeholderErrors} placeholder mismatch(es)`); if (carryoverTotal > 0) issues.push(`${carryoverTotal} suspected carryover(s)`); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index cf84935a4..ddde452e7 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -6,8 +6,9 @@ import { api } from './lib/api.js'; import { APP_NAME } from './lib/constants.js'; import { themeStore } from './lib/stores/theme.svelte.js'; - import { i18n, SUPPORTED_LOCALES } from './lib/stores/i18n.svelte.js'; + import { i18n, SUPPORTED_LOCALES, t } from './lib/stores/i18n.svelte.js'; import { safeLoginReturnPath } from './lib/utils/loginReturnPath.js'; + import { getStartupCopy } from './lib/utils/startupCopy.js'; import BrandedLoader from './lib/components/BrandedLoader.svelte'; import LazyRootDialog from './lib/components/LazyRootDialog.svelte'; import LazyRootView from './lib/components/LazyRootView.svelte'; @@ -36,6 +37,7 @@ let showWelcomeAssistant = $state(false); let startupError = $state(''); let startupSlow = $state(false); + let i18nReady = $state(false); let startupAttempt = 0; let themeAudience = null; let themeLoadGeneration = 0; @@ -82,6 +84,7 @@ try { // Initialize i18n (loads user's preferred locale) await withBootstrapDeadline(i18n.init()); + i18nReady = true; // Check setup status first await checkSetupStatus(); @@ -113,9 +116,7 @@ setupLoading = false; appInitialized = false; startupError = - error?.code === 'REQUEST_TIMEOUT' - ? 'The server took too long to respond.' - : 'Windshift could not connect to the server.'; + error?.code === 'REQUEST_TIMEOUT' ? 'errors.TIMEOUT' : 'errors.NETWORK_ERROR'; } finally { window.clearTimeout(slowTimer); } @@ -280,7 +281,9 @@ } - +
{APP_NAME} -

Unable to start Windshift

-

{startupError}

-

Check your connection or server, then try again.

+

+ {getStartupCopy('errors.failedToLoad', i18nReady, t)} Windshift +

+

{getStartupCopy(startupError, i18nReady, t)}

+ >{getStartupCopy('common.retry', i18nReady, t)}
{:else if setupLoading} {:else if $currentRoute.view === 'public-board'} @@ -358,13 +362,16 @@
{#if $authStore.loading} - + {:else if showLoginDialog}
Windshift

Windshift

-

Work Management

+

{t('footer.platformName')}

{/if}
diff --git a/frontend/src/lib/commands/providers/workspaceNavigationProvider.js b/frontend/src/lib/commands/providers/workspaceNavigationProvider.js index 337df884a..289032e3a 100644 --- a/frontend/src/lib/commands/providers/workspaceNavigationProvider.js +++ b/frontend/src/lib/commands/providers/workspaceNavigationProvider.js @@ -4,6 +4,7 @@ import { workspaceViewItems, } from '../../navigation/workspaceNavigation.js'; import { workspacePermissions } from '../../stores'; +import { t } from '../../stores/i18n.svelte.js'; import { BUCKET } from '../buckets.js'; import { createCommand } from '../types.js'; @@ -18,15 +19,14 @@ export function workspaceNavigationProvider(ctx) { const { workspaceId, workspace, collectionId, route, modules } = ctx; if (!workspaceId) return []; - const name = workspace?.name || 'Workspace'; - const collectionSuffix = collectionId ? ' in this collection' : ''; + const name = workspace?.name || t('common.workspace'); const out = []; out.push( createCommand({ id: 'workspace-overview', - label: `${name} Overview`, - description: collectionId ? 'Open this collection overview' : 'Open workspace overview', + label: t('commandPalette.commands.workspaceOverview.label', { name }), + description: t('commandPalette.commands.workspaceOverview.description'), bucket: BUCKET.WORKSPACE_NAVIGATION, keywords: ['overview', 'workspace', 'stats', name.toLowerCase()], url: collectionId @@ -36,13 +36,14 @@ export function workspaceNavigationProvider(ctx) { ); for (const view of workspaceViewItems) { + const label = t(view.labelKey); out.push( createCommand({ id: `workspace-${view.id}-view`, - label: `Open ${name} ${view.label}`, - description: `Switch to ${view.label.toLowerCase()} view${collectionSuffix}`, + label: `${name}: ${label}`, + description: t(view.tooltipKey || view.labelKey), bucket: BUCKET.WORKSPACE_NAVIGATION, - keywords: [view.id, view.label.toLowerCase(), name.toLowerCase()], + keywords: [view.id, label.toLowerCase(), name.toLowerCase()], url: buildViewUrl(workspaceId, view.id, collectionId), }) ); @@ -51,13 +52,14 @@ export function workspaceNavigationProvider(ctx) { if (!collectionId) { for (const view of workspaceOnlyViews) { if (view.id === 'agents' && !workspacePermissions.canAdminWorkspace(workspaceId)) continue; + const label = t(view.labelKey); out.push( createCommand({ id: `workspace-${view.id}-view`, - label: `Open ${name} ${view.label}`, - description: view.tooltip || '', + label: `${name}: ${label}`, + description: t(view.tooltipKey || view.labelKey), bucket: BUCKET.WORKSPACE_NAVIGATION, - keywords: [view.id, view.label.toLowerCase(), name.toLowerCase()], + keywords: [view.id, label.toLowerCase(), name.toLowerCase()], url: buildViewUrl(workspaceId, view.id, null), }) ); @@ -71,13 +73,14 @@ export function workspaceNavigationProvider(ctx) { ) { for (const view of testNavigationItems) { const slug = view.id === 'test-cases' ? 'tests' : `tests/${view.id.replace(/^test-/, '')}`; + const label = t(view.labelKey); out.push( createCommand({ id: `workspace-${view.id}`, - label: `${name} ${view.label}`, - description: view.tooltip || '', + label: `${name}: ${label}`, + description: t(view.tooltipKey || view.labelKey), bucket: BUCKET.WORKSPACE_NAVIGATION, - keywords: ['test', 'testing', 'qa', view.id, view.label.toLowerCase()], + keywords: ['test', 'testing', 'qa', view.id, label.toLowerCase()], url: `/workspaces/${workspaceId}/${slug}`, }) ); diff --git a/frontend/src/lib/components/BrandedLoader.svelte b/frontend/src/lib/components/BrandedLoader.svelte index 140ed49bb..b135ddcdf 100644 --- a/frontend/src/lib/components/BrandedLoader.svelte +++ b/frontend/src/lib/components/BrandedLoader.svelte @@ -1,7 +1,9 @@
-

{label}

+

{displayLabel}

{#if detail}

{detail}

{/if} diff --git a/frontend/src/lib/components/ItemsByStatusCategory.svelte b/frontend/src/lib/components/ItemsByStatusCategory.svelte index 82545c07e..ae61ee40c 100644 --- a/frontend/src/lib/components/ItemsByStatusCategory.svelte +++ b/frontend/src/lib/components/ItemsByStatusCategory.svelte @@ -2,6 +2,7 @@ import { IconChevronDown, IconChevronRight } from '@tabler/icons-svelte-runes'; import EmptyState from './EmptyState.svelte'; import { itemUrl } from '../utils/urls.js'; + import { systemPriorityName, systemStatusCategoryName } from '../utils/systemLabels.js'; let { statusBreakdown = [], @@ -37,7 +38,12 @@ class="w-3 h-3 rounded-full" style="background-color: {category.category_color || '#9ca3af'};" >
- {category.category_name} + + {systemStatusCategoryName({ + name: category.category_name, + builtin_key: category.category_builtin_key, + })} + ({category.item_count} item{category.item_count !== 1 ? 's' : ''})
@@ -62,7 +68,10 @@ class="text-xs px-2 py-0.5 rounded" style="background-color: {item.priority_color ? item.priority_color + '20' : 'var(--ds-background-neutral)'}; color: {item.priority_color || 'var(--ds-text-subtle)'};" > - {item.priority_name} + {systemPriorityName({ + name: item.priority_name, + builtin_key: item.priority_builtin_key, + })} {/if} {#if item.assignee_name} diff --git a/frontend/src/lib/components/LazyRootDialog.svelte b/frontend/src/lib/components/LazyRootDialog.svelte index f05056a46..a68cdaeb5 100644 --- a/frontend/src/lib/components/LazyRootDialog.svelte +++ b/frontend/src/lib/components/LazyRootDialog.svelte @@ -2,6 +2,7 @@ import Button from './Button.svelte'; import ModalBackdrop from './ModalBackdrop.svelte'; import Spinner from './Spinner.svelte'; + import { t } from '../stores/i18n.svelte.js'; let { loader, @@ -31,10 +32,11 @@ style="background-color: var(--ds-surface-raised); color: var(--ds-text); box-shadow: var(--ds-shadow-raised);" role="status" data-testid="root-dialog-loading" + data-root-label={label} >

- Loading {label}… + {t('common.loading')}

@@ -55,12 +57,13 @@ style="background-color: var(--ds-surface-raised); color: var(--ds-text); box-shadow: var(--ds-shadow-raised);" role="alert" data-testid="root-dialog-error" + data-root-label={label} >

- Unable to load {label} + {t('errors.failedToLoad')}

- Check your connection, then try again. + {t('errors.NETWORK_ERROR')}

+ >{t('common.retry')} {/await} diff --git a/frontend/src/lib/components/LazyRootView.svelte b/frontend/src/lib/components/LazyRootView.svelte index 76a39b3b8..f9aec93dc 100644 --- a/frontend/src/lib/components/LazyRootView.svelte +++ b/frontend/src/lib/components/LazyRootView.svelte @@ -1,6 +1,7 @@ @@ -112,8 +124,8 @@
{#snippet actions()} {#if canAdmin} @@ -126,7 +138,7 @@ hotkeyConfig={{ key: toHotkeyString('agents', 'add') }} dataTestid="agent-catalog-manage" > - Create agent + {t('workspaceAgents.catalog.createAgent')} {/if} {/snippet} @@ -135,17 +147,17 @@
{#if loading} - + {:else if error} {#if canAdmin}
@@ -168,7 +180,7 @@ icon={Plus} dataTestid="agent-catalog-empty-manage" > - Create agent + {t('workspaceAgents.catalog.createAgent')}
{/if} @@ -177,8 +189,8 @@ {:else} @@ -218,7 +230,7 @@

- {agent.purpose || 'A workspace specialist configured by your administrators.'} + {agent.purpose || t('workspaceAgents.catalog.defaultPurpose')}

@@ -234,19 +246,21 @@ {identityLabel(agent.identity_class)} - {agent.owner_name ? ` · Owned by ${agent.owner_name}` : ''} + {agent.owner_name + ? ` · ${t('workspaceAgents.catalog.ownedBy', { name: agent.owner_name })}` + : ''}
- {agent.model_summary || 'Model unavailable'} + {agent.model_summary || t('workspaceAgents.catalog.modelUnavailable')}
{agent.profile_type === 'standard' - ? 'Assignment, mentions, and workspace chat' - : 'Direct assignment and mentions'} + ? t('workspaceAgents.catalog.standardInteraction') + : t('workspaceAgents.catalog.codingInteraction')}
{#if recentRunByAgent[String(agent.id)]} @@ -254,7 +268,9 @@
- Last run {latestRun.status} · + {t('workspaceAgents.catalog.lastRun', { + status: runStatusLabel(latestRun.status), + })} · {new Date(latestRun.updated_at || latestRun.created_at).toLocaleDateString()}
@@ -263,7 +279,9 @@
- View profile · Version {agent.profile_version} + {t('workspaceAgents.catalog.viewProfileVersion', { + version: agent.profile_version, + })}
diff --git a/frontend/src/lib/features/collections/BoardColumn.svelte b/frontend/src/lib/features/collections/BoardColumn.svelte index de07a729c..895c7c3b3 100644 --- a/frontend/src/lib/features/collections/BoardColumn.svelte +++ b/frontend/src/lib/features/collections/BoardColumn.svelte @@ -1,6 +1,7 @@ -