Skip to content

Commit fecb53a

Browse files
authored
Add app locale loading and intl provider (#133)
* Add app locale loading and intl provider - Persist app locale preference in settings - Resolve system locale and load translated messages - Wire react-intl helpers for formatting and message lookup * 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 * Pin effect to unblock repo typecheck - Force a single `effect` version across `@effect/*` - Record the successful repo-wide `bun typecheck` in the rollout plan
1 parent e32e0f3 commit fecb53a

25 files changed

Lines changed: 1689 additions & 25 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+
- Resolved the repo-wide server typecheck blocker by forcing a single `effect` version across `@effect/*`
53+
- `apps/web` tests pass
54+
- `bun fmt` passed
55+
- `bun lint` passed
56+
- `bun typecheck` passed
57+
58+
Not yet completed:
59+
60+
- Settings page migration
61+
- Onboarding migration
62+
- Mobile pairing migration
63+
64+
## Phase 1 — Infrastructure
65+
66+
Objective:
67+
Establish the shared localization foundation without coupling it to the server or user content.
68+
69+
Checklist:
70+
71+
- [x] Add `react-intl` to `apps/web`
72+
- Status: `DONE`
73+
- [x] Add persisted locale preference to `apps/web/src/appSettings.ts`
74+
- Status: `DONE`
75+
- [x] Add shared i18n module in `apps/web/src/i18n/`
76+
- Status: `DONE`
77+
- [x] Add message catalogs for `en`, `es`, `fr`, and `zh-CN`
78+
- Status: `DONE`
79+
- [ ] Wire `I18nProvider` into [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
80+
- Status: `DONE`
81+
- [ ] Expose stable translation helpers for component usage
82+
- Status: `DONE`
83+
- [ ] Add locale-aware timestamp formatting in [apps/web/src/timestampFormat.ts](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/timestampFormat.ts)
84+
- Status: `DONE`
85+
- [ ] 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)
86+
- Status: `DONE`
87+
- [ ] Update timestamp callsites in [apps/web/src/components/DiffPanel.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/DiffPanel.tsx)
88+
- Status: `DONE`
89+
90+
Exit criteria:
91+
92+
- Locale can be resolved at runtime from `system | en | es | fr | zh-CN`
93+
- The app can render under a single root i18n provider
94+
- Timestamp formatting can follow the selected app locale
95+
96+
## Phase 2 — First Shippable Surfaces
97+
98+
Objective:
99+
Ship a coherent multilingual slice that is complete on the highest-value product-owned surfaces.
100+
101+
Checklist:
102+
103+
- [ ] 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)
104+
- Status: `TODO`
105+
- [ ] Migrate root keybinding toasts in [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
106+
- Status: `TODO`
107+
- [ ] 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)
108+
- Status: `TODO`
109+
- [ ] 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)
110+
- Status: `TODO`
111+
- [ ] Migrate supporting settings components in [apps/web/src/components/EnvironmentVariablesEditor.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/EnvironmentVariablesEditor.tsx)
112+
- Status: `TODO`
113+
- [ ] Migrate supporting settings components in [apps/web/src/components/CustomThemeDialog.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/CustomThemeDialog.tsx)
114+
- Status: `TODO`
115+
- [ ] 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)
116+
- Status: `TODO`
117+
- [ ] 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)
118+
- Status: `TODO`
119+
- [ ] 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)
120+
- Status: `TODO`
121+
122+
Exit criteria:
123+
124+
- Users can select a language in Settings without reloading
125+
- Root screens, Settings, Onboarding, and Mobile Pairing render localized product UI
126+
- English remains the safe fallback when a locale cannot be resolved or loaded
127+
128+
## Phase 3 — High-Traffic Product Surfaces
129+
130+
Objective:
131+
Extend localization to the most visible remaining chrome and toast-heavy flows.
132+
133+
Checklist:
134+
135+
- [ ] 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)
136+
- Status: `TODO`
137+
- [ ] 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)
138+
- Status: `TODO`
139+
- [ ] 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)
140+
- Status: `TODO`
141+
- [ ] 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)
142+
- Status: `TODO`
143+
- [ ] Migrate branch selector copy in [apps/web/src/components/BranchToolbarBranchSelector.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/BranchToolbarBranchSelector.tsx)
144+
- Status: `TODO`
145+
146+
Exit criteria:
147+
148+
- The highest-traffic app chrome and common toasts are localized
149+
- Remaining untranslated product UI is narrow and intentional
150+
151+
## Phase 4 — Hardening and Verification
152+
153+
Objective:
154+
Make the rollout safe to maintain and safe to ship repeatedly.
155+
156+
Checklist:
157+
158+
- [ ] Add locale resolution tests
159+
- Status: `DONE`
160+
- [ ] Add app settings default tests for locale
161+
- Status: `DONE`
162+
- [ ] Add message catalog parity tests
163+
- Status: `DONE`
164+
- [ ] Add timestamp formatting tests
165+
- Status: `DONE`
166+
- [ ] Run `bun fmt`
167+
- Status: `DONE`
168+
- [ ] Run `bun lint`
169+
- Status: `DONE`
170+
- [ ] Run `bun typecheck`
171+
- Status: `DONE`
172+
173+
Exit criteria:
174+
175+
- Catalog drift is caught by tests
176+
- Locale behavior is covered by automated checks
177+
- Required repository quality gates pass
178+
179+
## Shippable Stop
180+
181+
The first shippable stop is:
182+
183+
- Phase 1 complete
184+
- Phase 2 complete
185+
- Phase 4 verification complete
186+
187+
Phase 3 can follow later without blocking the first release if the app’s core localized surfaces are already coherent.
188+
189+
## Next Up
190+
191+
Immediate next implementation steps:
192+
193+
1. Migrate root route strings and root toasts.
194+
2. Migrate Settings and its supporting components.
195+
3. Migrate Onboarding and Mobile Pairing.
196+
4. Continue with Phase 2 completion toward the first shippable localized stop.

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,7 @@ describe("ProviderCommandReactor", () => {
356356
});
357357

358358
const readModel = await Effect.runPromise(harness.engine.getReadModel());
359-
const thread = readModel.threads.find(
360-
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
361-
);
359+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
362360
expect(thread?.worktreePath).toBeNull();
363361
});
364362

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DEFAULT_GIT_TEXT_GENERATION_MODEL,
77
EventId,
88
type OrchestrationEvent,
9+
type ProjectId,
910
type ProviderModelOptions,
1011
ProviderKind,
1112
type ProviderStartOptions,
@@ -145,11 +146,11 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
145146
function resolveSessionCwd(input: {
146147
readonly thread: {
147148
readonly id: ThreadId;
148-
readonly projectId: string;
149+
readonly projectId: ProjectId;
149150
readonly worktreePath: string | null;
150151
};
151152
readonly projects: ReadonlyArray<{
152-
readonly id: string;
153+
readonly id: ProjectId;
153154
readonly workspaceRoot: string;
154155
}>;
155156
}): {

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"oxfmt": "^0.42.0",
4545
"react": "^19.0.0",
4646
"react-dom": "^19.0.0",
47+
"react-intl": "^10.1.1",
4748
"react-markdown": "^10.1.0",
4849
"remark-gfm": "^4.0.1",
4950
"tailwind-merge": "^3.4.0",

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/appSettings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
normalizeModelSlug,
1212
resolveSelectableModel,
1313
} from "@okcode/shared/model";
14+
import { APP_LOCALE_PREFERENCES } from "./i18n/types";
1415
import { useLocalStorage } from "./hooks/useLocalStorage";
1516
import { EnvMode } from "./components/BranchToolbar.logic";
1617

@@ -21,6 +22,9 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
2122
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
2223
export type TimestampFormat = typeof TimestampFormat.Type;
2324
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
25+
export const AppLocale = Schema.Literals(APP_LOCALE_PREFERENCES);
26+
export type AppLocale = typeof AppLocale.Type;
27+
export const DEFAULT_APP_LOCALE: AppLocale = "system";
2428
export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
2529
export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
2630
export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
@@ -64,6 +68,7 @@ export const AppSettingsSchema = Schema.Struct({
6468
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
6569
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
6670
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
71+
locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)),
6772
openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)),
6873
sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
6974
withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),

apps/web/src/components/DiffPanel.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "../lib/diffFileReviewState";
2424
import { resolveDiffThemeName } from "../lib/diffRendering";
2525
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
26+
import { useI18n } from "../i18n/useI18n";
2627
import { useStore } from "../store";
2728
import { useAppSettings } from "../appSettings";
2829
import { formatShortTimestamp } from "../timestampFormat";
@@ -349,6 +350,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
349350
export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
350351
const navigate = useNavigate();
351352
const { resolvedTheme } = useTheme();
353+
const { resolvedLocale } = useI18n();
352354
const { settings } = useAppSettings();
353355
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
354356
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
@@ -641,14 +643,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
641643
{selectedTurnId === null
642644
? "All changes"
643645
: selectedTurn?.turnId === latestSelectedTurnId
644-
? `Latest • ${formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat)}`
646+
? `Latest • ${formatShortTimestamp(
647+
selectedTurn.completedAt,
648+
settings.timestampFormat,
649+
resolvedLocale,
650+
)}`
645651
: `Change ${
646652
selectedTurn?.checkpointTurnCount ??
647653
(selectedTurn
648654
? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]
649655
: null) ??
650656
"?"
651-
}${selectedTurn ? formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat) : ""}`}
657+
}${
658+
selectedTurn
659+
? formatShortTimestamp(
660+
selectedTurn.completedAt,
661+
settings.timestampFormat,
662+
resolvedLocale,
663+
)
664+
: ""
665+
}`}
652666
</SelectButton>
653667
<SelectPopup>
654668
<SelectItem value="all">All changes</SelectItem>
@@ -665,7 +679,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
665679
}`}
666680
</span>
667681
<span className="text-muted-foreground text-xs">
668-
{formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
682+
{formatShortTimestamp(
683+
summary.completedAt,
684+
settings.timestampFormat,
685+
resolvedLocale,
686+
)}
669687
</span>
670688
</span>
671689
</SelectItem>

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,7 @@ export const ChatHeader = memo(function ChatHeader({
127127
/>
128128
)}
129129
{activeProjectName && hasCodeViewerTabs && (
130-
<OpenInPicker
131-
codeViewerOpen={codeViewerOpen}
132-
onToggleCodeViewer={onToggleCodeViewer}
133-
/>
130+
<OpenInPicker codeViewerOpen={codeViewerOpen} onToggleCodeViewer={onToggleCodeViewer} />
134131
)}
135132
{!isMobileCompanion && activeProjectName && (
136133
<GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />

0 commit comments

Comments
 (0)