From eed74724ac7d0ab4d8c8741b44acdcacea4eccea Mon Sep 17 00:00:00 2001 From: 5Hyeons Date: Tue, 4 Aug 2026 01:45:00 -0700 Subject: [PATCH] fix(i18n): localize automation contextual tour (#12270) The shared Automation tour copy was rendered without passing through translate(), and the overlay surface hardcoded its default Next and Done labels. Copy is keyed off the step id rather than its position, so inserting a step ahead of them cannot shift the text onto the wrong step. Co-authored-by: 5Hyeons --- config/scripts/locale-ko-key-overrides.json | 15 +++ ...alTourOverlaySurface.localization.test.tsx | 56 +++++++++++ .../ContextualTourOverlaySurface.tsx | 7 +- ...ontextual-tour-overlay-measurement.test.ts | 94 ++++++++++++++++++- .../contextual-tour-overlay-measurement.ts | 44 ++++++++- src/renderer/src/i18n/locales/en.json | 15 ++- src/renderer/src/i18n/locales/ko.json | 15 ++- src/shared/contextual-tours.ts | 6 +- 8 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 9b40ad61f50..da2d69dc68e 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -752,6 +752,21 @@ "auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": { "ko": "{{value1}}단계 중 {{value0}}단계" }, + "auto.components.contextual.tours.ContextualTourOverlaySurface.complete": { + "ko": "완료" + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": { + "ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요." + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": { + "ko": "자동화란 무엇인가요?" + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": { + "ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다." + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": { + "ko": "결과 확인" + }, "auto.components.crash.report.CrashReportDialog.88fea8e84e": { "ko": "보내지 않음" }, diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx new file mode 100644 index 00000000000..b9923d0fc11 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import type { ReactElement, RefObject } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setRendererUiLanguage } from '@/i18n/i18n' +import { + ContextualTourOverlaySurface, + handleContextualTourOverlayKeyDown, + type ActiveTourRenderState +} from './ContextualTourOverlaySurface' + +afterEach(async () => { + await setRendererUiLanguage('en') +}) + +function renderSurface(isLastStep: boolean): ReactElement { + const panelRef: RefObject = { current: null } + const renderState: ActiveTourRenderState = { + rect: new DOMRect(0, 0, 20, 20), + targetElement: document.createElement('button'), + progress: { current: isLastStep ? 2 : 1, total: 2 }, + title: 'Tour title', + body: 'Tour body', + isLastStep, + isFirstStep: !isLastStep, + panelHost: null + } + return ( + + ) +} + +describe('ContextualTourOverlaySurface localization', () => { + it('renders default tour actions in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + + const firstStep = renderToStaticMarkup(renderSurface(false)) + const finalStep = renderToStaticMarkup(renderSurface(true)) + + expect(firstStep).toContain('다음') + expect(firstStep).not.toContain('>Next<') + expect(finalStep).toContain('완료') + expect(finalStep).not.toContain('>Done<') + }) +}) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index a692a6cdaf1..5b6284ae648 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -107,7 +107,12 @@ export function ContextualTourOverlaySurface({ const stepKey = `${activeTourId}-${renderState.progress.current}` const defaultPrimaryAction = { kind: renderState.isLastStep ? 'complete' : 'next', - label: renderState.isLastStep ? 'Done' : 'Next' + label: renderState.isLastStep + ? translate('auto.components.contextual.tours.ContextualTourOverlaySurface.complete', 'Done') + : translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.38b3155418', + 'Next' + ) } satisfies ContextualTourStepAction const primaryAction = renderState.primaryAction ?? (renderState.hidePrimaryAction ? null : defaultPrimaryAction) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts index d83fafd3fc1..4b2ce9631ca 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts @@ -1,12 +1,104 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' import { getContextualTour } from '../../../../shared/contextual-tours' +import { setRendererUiLanguage } from '@/i18n/i18n' import { getContextualTourDisplayProgress, getContextualTourMeasurementAction, + measureContextualTourOverlayRenderState, isContextualTourLastDisplayStep } from './contextual-tour-overlay-measurement' +afterEach(async () => { + document.body.replaceChildren() + await setRendererUiLanguage('en') +}) + describe('contextual tour overlay measurement', () => { + it('renders the automation tour copy in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('button') + target.setAttribute('data-contextual-tour-target', 'automations-create') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const result = measureContextualTourOverlayRenderState({ + tour: getContextualTour('automations'), + activeStepIndex: 0, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('자동화란 무엇인가요?') + expect(result.renderState.body).toBe( + '자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요.' + ) + }) + + it('renders the automation results step in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('div') + target.setAttribute('data-contextual-tour-target', 'automations-runs') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const result = measureContextualTourOverlayRenderState({ + tour: getContextualTour('automations'), + activeStepIndex: 1, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('결과 확인') + expect(result.renderState.body).toBe( + '실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다.' + ) + }) + + it('keeps localized copy on its own step when a step is inserted before it', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('button') + target.setAttribute('data-contextual-tour-target', 'automations-create') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const automations = getContextualTour('automations') + const result = measureContextualTourOverlayRenderState({ + tour: { + ...automations, + steps: [ + { + title: 'Inserted step', + body: 'Added ahead of the localized steps.', + targetSelector: '[data-contextual-tour-target="automations-create"]' + }, + ...automations.steps + ] + }, + activeStepIndex: 1, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('자동화란 무엇인가요?') + }) + it('shows all defined browser steps in progress even when step 3 is hidden', () => { const tour = getContextualTour('browser') diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts index a807ade9fe4..1fe945a42cb 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts @@ -28,6 +28,36 @@ export type ContextualTourOverlayMeasurementResult = telemetryTotalSteps: number } +// Why: keyed by the step's stable id, not its position — inserting a step must +// not shift localized copy onto a neighbour. Thunks keep translate() out of +// module scope so the lookup resolves in the language active at render time. +const LOCALIZED_STEP_COPY: Record string; body: () => string }> = { + 'automations-intro': { + title: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title', + 'What is an automation?' + ), + body: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body', + 'Automations run agent work on a schedule. Add an automation by clicking this button.' + ) + }, + 'automations-results': { + title: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title', + 'Find the results' + ), + body: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body', + 'Runs show when automations ran, what happened, and where to inspect their output.' + ) + } +} + export function getContextualTourDisplayProgress(args: { tour: ContextualTour visibleStepIndexes: readonly number[] @@ -89,6 +119,13 @@ export function measureContextualTourOverlayRenderState(args: { ) const activeStep = args.tour.steps[args.activeStepIndex] const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null + const localizedCopy = activeStep?.id ? LOCALIZED_STEP_COPY[activeStep.id] : undefined + const localizedTitle = localizedCopy ? localizedCopy.title() : activeStep?.title + const localizedBody = localizedCopy + ? localizedCopy.body() + : activeStep + ? getContextualTourStepCopy(activeStep) + : undefined const progress = getContextualTourDisplayProgress({ tour: args.tour, visibleStepIndexes, @@ -135,8 +172,11 @@ export function measureContextualTourOverlayRenderState(args: { rect: target.rect, targetElement: target.element, progress, - title: activeStep.title, - body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), args.keybindings), + title: localizedTitle ?? activeStep.title, + body: formatContextualTourStepCopy( + localizedBody ?? getContextualTourStepCopy(activeStep), + args.keybindings + ), control: activeStep.control, primaryAction, secondaryAction, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 30ff9561d26..b3ce98a4edd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13500,7 +13500,8 @@ "4a9568f773": "Back", "4f86e2a10b": "Skip tour", "d974f32a83": "Dismiss tour", - "ffa4412b66": "next" + "ffa4412b66": "next", + "complete": "Done" }, "ContextualTourProgressDots": { "7734cb8ad3": "of", @@ -13510,7 +13511,17 @@ "tour": { "overlay": { "measurement": { - "38b3155418": "Next" + "38b3155418": "Next", + "automations": { + "intro": { + "title": "What is an automation?", + "body": "Automations run agent work on a schedule. Add an automation by clicking this button." + }, + "results": { + "title": "Find the results", + "body": "Runs show when automations ran, what happened, and where to inspect their output." + } + } } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ac8c5964157..e45434e3f6e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13294,7 +13294,8 @@ "4a9568f773": "뒤로", "4f86e2a10b": "둘러보기 건너뛰기", "d974f32a83": "투어 닫기", - "ffa4412b66": "다음" + "ffa4412b66": "다음", + "complete": "완료" }, "ContextualTourProgressDots": { "7734cb8ad3": "중", @@ -13304,7 +13305,17 @@ "tour": { "overlay": { "measurement": { - "38b3155418": "다음" + "38b3155418": "다음", + "automations": { + "intro": { + "title": "자동화란 무엇인가요?", + "body": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요." + }, + "results": { + "title": "결과 확인", + "body": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다." + } + } } } } diff --git a/src/shared/contextual-tours.ts b/src/shared/contextual-tours.ts index e57161d3860..623a0bbd6ec 100644 --- a/src/shared/contextual-tours.ts +++ b/src/shared/contextual-tours.ts @@ -30,6 +30,8 @@ export type ContextualTourStepAction = { export type ContextualTourStepPlacement = 'top' | 'right' | 'bottom' | 'left' export type ContextualTourStep = { + // Stable anchor for localized copy — position-keyed translations shift onto the wrong step when one is inserted. + id?: string title: string body: string targetSelector: string @@ -146,14 +148,16 @@ export const CONTEXTUAL_TOURS = [ id: 'automations', steps: [ { + id: 'automations-intro', title: 'What is an automation?', body: 'Automations run agent work on a schedule. Add an automation by clicking this button.', targetSelector: '[data-contextual-tour-target="automations-create"]', requiredForStart: true }, { + id: 'automations-results', title: 'Find the results', - body: 'Runs show when automations executed, what happened, and where to inspect their output.', + body: 'Runs show when automations ran, what happened, and where to inspect their output.', targetSelector: '[data-contextual-tour-target="automations-runs"]' } ]