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 <ohs2251@naver.com>
This commit is contained in:
5Hyeons
2026-08-04 03:56:33 -07:00
committed by Neil
parent 6bf34421d2
commit eed74724ac
8 changed files with 243 additions and 9 deletions
@@ -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": "보내지 않음"
},
@@ -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<HTMLElement | null> = { 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 (
<ContextualTourOverlaySurface
activeTourId="automations"
renderState={renderState}
panelRef={panelRef}
panelHost={null}
onSkip={vi.fn()}
onBack={vi.fn()}
onNext={vi.fn()}
onStepAction={vi.fn()}
onOverlayKeyDownCapture={handleContextualTourOverlayKeyDown}
/>
)
}
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<')
})
})
@@ -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)
@@ -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')
@@ -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, { title: () => 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,
+13 -2
View File
@@ -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."
}
}
}
}
}
+13 -2
View File
@@ -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": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
}
}
}
}
}
+5 -1
View File
@@ -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"]'
}
]