Skip to content

Commit bb25104

Browse files
committed
Add web i18n scaffolding and locale-aware timestamps
- Add message catalogs and locale resolution for app settings - Wire the root provider and update chat/diff timestamp formatting - Add guardrail tests for locale resolution and catalog parity
1 parent 33ba17f commit bb25104

16 files changed

Lines changed: 1399 additions & 28 deletions

.plans/web-i18n-rollout.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Web i18n Rollout Tracker
2+
3+
_Last updated: 2026-03-31_
4+
5+
This document tracks the phased rollout of multilingual support for `apps/web`.
6+
7+
Supported locales:
8+
9+
- `en`
10+
- `es`
11+
- `fr`
12+
- `zh-CN`
13+
14+
Status values:
15+
16+
- `TODO`: Not started
17+
- `IN_PROGRESS`: Started but not yet shippable
18+
- `DONE`: Implemented and verified
19+
- `BLOCKED`: Waiting on a dependency or decision
20+
21+
## Scope
22+
23+
In scope for this rollout:
24+
25+
- frontend-only localization in `apps/web`
26+
- product-owned UI strings only
27+
- locale persistence via app settings
28+
- locale-aware timestamps
29+
- root screens, settings, onboarding, and mobile pairing in the first shippable stop
30+
31+
Out of scope for this rollout:
32+
33+
- `apps/server`
34+
- `apps/marketing`
35+
- user/model/code content translation
36+
- arbitrary provider/server freeform error translation
37+
38+
## Current Snapshot
39+
40+
Overall status: `IN_PROGRESS`
41+
42+
Completed so far:
43+
44+
- Added `react-intl` to `apps/web`
45+
- Added locale schema support to `apps/web/src/appSettings.ts`
46+
- Added the shared i18n scaffolding under `apps/web/src/i18n/`
47+
- Added initial message catalogs for `en`, `es`, `fr`, and `zh-CN`
48+
- Wired the root route through a shared `I18nProvider`
49+
- Made timestamps honor the resolved app locale
50+
- Updated the timestamp callsites in chat and diff surfaces
51+
- Added Phase 1 guardrail tests for locale resolution, timestamp formatting, and catalog parity
52+
- `apps/web` tests pass
53+
- `bun fmt` passed
54+
- `bun lint` passed
55+
56+
Not yet completed:
57+
58+
- Settings page migration
59+
- Onboarding migration
60+
- Mobile pairing migration
61+
- Repo-wide `bun typecheck` currently fails in `apps/server` on unrelated Effect/FileSystem context issues
62+
63+
## Phase 1 — Infrastructure
64+
65+
Objective:
66+
Establish the shared localization foundation without coupling it to the server or user content.
67+
68+
Checklist:
69+
70+
- [x] Add `react-intl` to `apps/web`
71+
- Status: `DONE`
72+
- [x] Add persisted locale preference to `apps/web/src/appSettings.ts`
73+
- Status: `DONE`
74+
- [x] Add shared i18n module in `apps/web/src/i18n/`
75+
- Status: `DONE`
76+
- [x] Add message catalogs for `en`, `es`, `fr`, and `zh-CN`
77+
- Status: `DONE`
78+
- [ ] Wire `I18nProvider` into [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
79+
- Status: `DONE`
80+
- [ ] Expose stable translation helpers for component usage
81+
- Status: `DONE`
82+
- [ ] Add locale-aware timestamp formatting in [apps/web/src/timestampFormat.ts](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/timestampFormat.ts)
83+
- Status: `DONE`
84+
- [ ] Update timestamp callsites in [apps/web/src/components/chat/MessagesTimeline.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/chat/MessagesTimeline.tsx)
85+
- Status: `DONE`
86+
- [ ] Update timestamp callsites in [apps/web/src/components/DiffPanel.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/DiffPanel.tsx)
87+
- Status: `DONE`
88+
89+
Exit criteria:
90+
91+
- Locale can be resolved at runtime from `system | en | es | fr | zh-CN`
92+
- The app can render under a single root i18n provider
93+
- Timestamp formatting can follow the selected app locale
94+
95+
## Phase 2 — First Shippable Surfaces
96+
97+
Objective:
98+
Ship a coherent multilingual slice that is complete on the highest-value product-owned surfaces.
99+
100+
Checklist:
101+
102+
- [ ] Migrate root route loading/error copy in [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
103+
- Status: `TODO`
104+
- [ ] Migrate root keybinding toasts in [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
105+
- Status: `TODO`
106+
- [ ] Add language selector to [apps/web/src/routes/_chat.settings.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/_chat.settings.tsx)
107+
- Status: `TODO`
108+
- [ ] Migrate product-owned settings copy in [apps/web/src/routes/_chat.settings.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/_chat.settings.tsx)
109+
- Status: `TODO`
110+
- [ ] Migrate supporting settings components in [apps/web/src/components/EnvironmentVariablesEditor.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/EnvironmentVariablesEditor.tsx)
111+
- Status: `TODO`
112+
- [ ] Migrate supporting settings components in [apps/web/src/components/CustomThemeDialog.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/CustomThemeDialog.tsx)
113+
- Status: `TODO`
114+
- [ ] Migrate onboarding content in [apps/web/src/components/onboarding/onboardingSteps.ts](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/onboarding/onboardingSteps.ts)
115+
- Status: `TODO`
116+
- [ ] Migrate onboarding controls in [apps/web/src/components/onboarding/OnboardingDialog.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/onboarding/OnboardingDialog.tsx)
117+
- Status: `TODO`
118+
- [ ] Migrate mobile pairing UI in [apps/web/src/components/mobile/MobilePairingScreen.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/mobile/MobilePairingScreen.tsx)
119+
- Status: `TODO`
120+
121+
Exit criteria:
122+
123+
- Users can select a language in Settings without reloading
124+
- Root screens, Settings, Onboarding, and Mobile Pairing render localized product UI
125+
- English remains the safe fallback when a locale cannot be resolved or loaded
126+
127+
## Phase 3 — High-Traffic Product Surfaces
128+
129+
Objective:
130+
Extend localization to the most visible remaining chrome and toast-heavy flows.
131+
132+
Checklist:
133+
134+
- [ ] Migrate sidebar toasts and chrome in [apps/web/src/components/Sidebar.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/Sidebar.tsx)
135+
- Status: `TODO`
136+
- [ ] Migrate chat home empty state in [apps/web/src/components/ChatHomeEmptyState.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/ChatHomeEmptyState.tsx)
137+
- Status: `TODO`
138+
- [ ] Migrate workspace file tree messages in [apps/web/src/components/WorkspaceFileTree.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/WorkspaceFileTree.tsx)
139+
- Status: `TODO`
140+
- [ ] Migrate Git actions UI copy in [apps/web/src/components/GitActionsControl.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/GitActionsControl.tsx)
141+
- Status: `TODO`
142+
- [ ] Migrate branch selector copy in [apps/web/src/components/BranchToolbarBranchSelector.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/BranchToolbarBranchSelector.tsx)
143+
- Status: `TODO`
144+
145+
Exit criteria:
146+
147+
- The highest-traffic app chrome and common toasts are localized
148+
- Remaining untranslated product UI is narrow and intentional
149+
150+
## Phase 4 — Hardening and Verification
151+
152+
Objective:
153+
Make the rollout safe to maintain and safe to ship repeatedly.
154+
155+
Checklist:
156+
157+
- [ ] Add locale resolution tests
158+
- Status: `DONE`
159+
- [ ] Add app settings default tests for locale
160+
- Status: `DONE`
161+
- [ ] Add message catalog parity tests
162+
- Status: `DONE`
163+
- [ ] Add timestamp formatting tests
164+
- Status: `DONE`
165+
- [ ] Run `bun fmt`
166+
- Status: `DONE`
167+
- [ ] Run `bun lint`
168+
- Status: `DONE`
169+
- [ ] Run `bun typecheck`
170+
- Status: `BLOCKED`
171+
172+
Exit criteria:
173+
174+
- Catalog drift is caught by tests
175+
- Locale behavior is covered by automated checks
176+
- Required repository quality gates pass
177+
178+
## Shippable Stop
179+
180+
The first shippable stop is:
181+
182+
- Phase 1 complete
183+
- Phase 2 complete
184+
- Phase 4 verification complete
185+
186+
Phase 3 can follow later without blocking the first release if the app’s core localized surfaces are already coherent.
187+
188+
## Next Up
189+
190+
Immediate next implementation steps:
191+
192+
1. Resolve the unrelated `apps/server` typecheck failures so repo-wide verification can pass again.
193+
2. Migrate root route strings and root toasts.
194+
3. Migrate Settings and its supporting components.
195+
4. Migrate Onboarding and Mobile Pairing.
196+
5. Continue with Phase 2 completion toward the first shippable localized stop.

apps/web/src/appSettings.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AppSettingsSchema,
66
DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
77
DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
8+
DEFAULT_APP_LOCALE,
89
DEFAULT_TIMESTAMP_FORMAT,
910
getAppModelOptions,
1011
getCustomModelOptionsByProvider,
@@ -258,6 +259,7 @@ describe("AppSettingsSchema", () => {
258259
defaultThreadEnvMode: "worktree",
259260
confirmThreadDelete: false,
260261
enableAssistantStreaming: false,
262+
locale: DEFAULT_APP_LOCALE,
261263
sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
262264
sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
263265
timestampFormat: DEFAULT_TIMESTAMP_FORMAT,

apps/web/src/components/DiffPanel.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from "../lib/diffFileReviewState";
2222
import { resolveDiffThemeName } from "../lib/diffRendering";
2323
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
24+
import { useI18n } from "../i18n/useI18n";
2425
import { useStore } from "../store";
2526
import { useAppSettings } from "../appSettings";
2627
import { formatShortTimestamp } from "../timestampFormat";
@@ -268,6 +269,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
268269
export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
269270
const navigate = useNavigate();
270271
const { resolvedTheme } = useTheme();
272+
const { resolvedLocale } = useI18n();
271273
const { settings } = useAppSettings();
272274
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
273275
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
@@ -554,14 +556,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
554556
{selectedTurnId === null
555557
? "All changes"
556558
: selectedTurn?.turnId === latestSelectedTurnId
557-
? `Latest • ${formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat)}`
559+
? `Latest • ${formatShortTimestamp(
560+
selectedTurn.completedAt,
561+
settings.timestampFormat,
562+
resolvedLocale,
563+
)}`
558564
: `Change ${
559565
selectedTurn?.checkpointTurnCount ??
560566
(selectedTurn
561567
? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]
562568
: null) ??
563569
"?"
564-
}${selectedTurn ? formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat) : ""}`}
570+
}${
571+
selectedTurn
572+
? formatShortTimestamp(
573+
selectedTurn.completedAt,
574+
settings.timestampFormat,
575+
resolvedLocale,
576+
)
577+
: ""
578+
}`}
565579
</SelectButton>
566580
<SelectPopup>
567581
<SelectItem value="all">All changes</SelectItem>
@@ -578,7 +592,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
578592
}`}
579593
</span>
580594
<span className="text-muted-foreground text-xs">
581-
{formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
595+
{formatShortTimestamp(
596+
summary.completedAt,
597+
settings.timestampFormat,
598+
resolvedLocale,
599+
)}
582600
</span>
583601
</span>
584602
</SelectItem>

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { MessageId } from "@okcode/contracts";
2+
import type { ReactElement } from "react";
23
import { renderToStaticMarkup } from "react-dom/server";
34
import { beforeAll, describe, expect, it, vi } from "vitest";
45

56
import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance";
7+
import { I18nProvider } from "~/i18n/I18nProvider";
68

79
function matchMedia() {
810
return {
@@ -36,6 +38,9 @@ beforeAll(() => {
3638
documentElement: {
3739
classList,
3840
offsetHeight: 0,
41+
style: {
42+
setProperty: () => {},
43+
},
3944
},
4045
});
4146
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
@@ -46,10 +51,14 @@ beforeAll(() => {
4651

4752
const EMPTY_SHORTCUT_GUIDES = buildChatShortcutGuides([], "Win32");
4853

54+
function renderWithI18n(element: ReactElement) {
55+
return renderToStaticMarkup(<I18nProvider>{element}</I18nProvider>);
56+
}
57+
4958
describe("MessagesTimeline", () => {
5059
it("renders inline terminal labels with the composer chip UI", async () => {
5160
const { MessagesTimeline } = await import("./MessagesTimeline");
52-
const markup = renderToStaticMarkup(
61+
const markup = renderWithI18n(
5362
<MessagesTimeline
5463
hasMessages
5564
isWorking={false}
@@ -105,7 +114,7 @@ describe("MessagesTimeline", () => {
105114

106115
it("renders context compaction entries in the normal work log", async () => {
107116
const { MessagesTimeline } = await import("./MessagesTimeline");
108-
const markup = renderToStaticMarkup(
117+
const markup = renderWithI18n(
109118
<MessagesTimeline
110119
hasMessages
111120
isWorking={false}
@@ -151,7 +160,7 @@ describe("MessagesTimeline", () => {
151160

152161
it("renders shortcut guidance when the timeline is empty", async () => {
153162
const { MessagesTimeline } = await import("./MessagesTimeline");
154-
const markup = renderToStaticMarkup(
163+
const markup = renderWithI18n(
155164
<MessagesTimeline
156165
hasMessages={false}
157166
isWorking={false}

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
import { cn } from "~/lib/utils";
5454
import { type TimestampFormat } from "../../appSettings";
5555
import { formatTimestamp } from "../../timestampFormat";
56+
import { useI18n } from "../../i18n/useI18n";
5657
import {
5758
buildInlineTerminalContextText,
5859
formatInlineTerminalContextLabel,
@@ -113,6 +114,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
113114
shortcutGuides,
114115
onOpenSettings,
115116
}: MessagesTimelineProps) {
117+
const { resolvedLocale } = useI18n();
116118
const timelineRootRef = useRef<HTMLDivElement | null>(null);
117119
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
118120

@@ -467,7 +469,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
467469
</span>
468470
)}
469471
<p className="text-right text-[10px] text-muted-foreground/30">
470-
{formatTimestamp(row.message.createdAt, timestampFormat)}
472+
{formatTimestamp(row.message.createdAt, timestampFormat, resolvedLocale)}
471473
</p>
472474
</div>
473475
</div>
@@ -579,6 +581,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
579581
? formatElapsed(row.durationStart, nowIso)
580582
: formatElapsed(row.durationStart, row.message.completedAt),
581583
timestampFormat,
584+
resolvedLocale,
582585
)}
583586
</p>
584587
</div>
@@ -795,9 +798,10 @@ function formatMessageMeta(
795798
createdAt: string,
796799
duration: string | null,
797800
timestampFormat: TimestampFormat,
801+
locale: ReturnType<typeof useI18n>["resolvedLocale"],
798802
): string {
799-
if (!duration) return formatTimestamp(createdAt, timestampFormat);
800-
return `${formatTimestamp(createdAt, timestampFormat)}${duration}`;
803+
if (!duration) return formatTimestamp(createdAt, timestampFormat, locale);
804+
return `${formatTimestamp(createdAt, timestampFormat, locale)}${duration}`;
801805
}
802806

803807
const UserMessageTerminalContextInlineLabel = memo(

0 commit comments

Comments
 (0)