Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions .plans/web-i18n-rollout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Web i18n Rollout Tracker

_Last updated: 2026-03-31_

This document tracks the phased rollout of multilingual support for `apps/web`.

Supported locales:

- `en`
- `es`
- `fr`
- `zh-CN`

Status values:

- `TODO`: Not started
- `IN_PROGRESS`: Started but not yet shippable
- `DONE`: Implemented and verified
- `BLOCKED`: Waiting on a dependency or decision

## Scope

In scope for this rollout:

- frontend-only localization in `apps/web`
- product-owned UI strings only
- locale persistence via app settings
- locale-aware timestamps
- root screens, settings, onboarding, and mobile pairing in the first shippable stop

Out of scope for this rollout:

- `apps/server`
- `apps/marketing`
- user/model/code content translation
- arbitrary provider/server freeform error translation

## Current Snapshot

Overall status: `IN_PROGRESS`

Completed so far:

- Added `react-intl` to `apps/web`
- Added locale schema support to `apps/web/src/appSettings.ts`
- Added the shared i18n scaffolding under `apps/web/src/i18n/`
- Added initial message catalogs for `en`, `es`, `fr`, and `zh-CN`
- Wired the root route through a shared `I18nProvider`
- Made timestamps honor the resolved app locale
- Updated the timestamp callsites in chat and diff surfaces
- Added Phase 1 guardrail tests for locale resolution, timestamp formatting, and catalog parity
- Resolved the repo-wide server typecheck blocker by forcing a single `effect` version across `@effect/*`
- `apps/web` tests pass
- `bun fmt` passed
- `bun lint` passed
- `bun typecheck` passed

Not yet completed:

- Settings page migration
- Onboarding migration
- Mobile pairing migration

## Phase 1 — Infrastructure

Objective:
Establish the shared localization foundation without coupling it to the server or user content.

Checklist:

- [x] Add `react-intl` to `apps/web`
- Status: `DONE`
- [x] Add persisted locale preference to `apps/web/src/appSettings.ts`
- Status: `DONE`
- [x] Add shared i18n module in `apps/web/src/i18n/`
- Status: `DONE`
- [x] Add message catalogs for `en`, `es`, `fr`, and `zh-CN`
- Status: `DONE`
- [ ] Wire `I18nProvider` into [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
- Status: `DONE`
- [ ] Expose stable translation helpers for component usage
- Status: `DONE`
- [ ] Add locale-aware timestamp formatting in [apps/web/src/timestampFormat.ts](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/timestampFormat.ts)
- Status: `DONE`
- [ ] 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)
- Status: `DONE`
- [ ] Update timestamp callsites in [apps/web/src/components/DiffPanel.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/DiffPanel.tsx)
- Status: `DONE`

Exit criteria:

- Locale can be resolved at runtime from `system | en | es | fr | zh-CN`
- The app can render under a single root i18n provider
- Timestamp formatting can follow the selected app locale

## Phase 2 — First Shippable Surfaces

Objective:
Ship a coherent multilingual slice that is complete on the highest-value product-owned surfaces.

Checklist:

- [ ] 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)
- Status: `TODO`
- [ ] Migrate root keybinding toasts in [apps/web/src/routes/__root.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/routes/__root.tsx)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] Migrate supporting settings components in [apps/web/src/components/EnvironmentVariablesEditor.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/EnvironmentVariablesEditor.tsx)
- Status: `TODO`
- [ ] Migrate supporting settings components in [apps/web/src/components/CustomThemeDialog.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/CustomThemeDialog.tsx)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`

Exit criteria:

- Users can select a language in Settings without reloading
- Root screens, Settings, Onboarding, and Mobile Pairing render localized product UI
- English remains the safe fallback when a locale cannot be resolved or loaded

## Phase 3 — High-Traffic Product Surfaces

Objective:
Extend localization to the most visible remaining chrome and toast-heavy flows.

Checklist:

- [ ] 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)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] 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)
- Status: `TODO`
- [ ] Migrate branch selector copy in [apps/web/src/components/BranchToolbarBranchSelector.tsx](/Users/buns/.okcode/worktrees/okcode/okcode-587a5d98/apps/web/src/components/BranchToolbarBranchSelector.tsx)
- Status: `TODO`

Exit criteria:

- The highest-traffic app chrome and common toasts are localized
- Remaining untranslated product UI is narrow and intentional

## Phase 4 — Hardening and Verification

Objective:
Make the rollout safe to maintain and safe to ship repeatedly.

Checklist:

- [ ] Add locale resolution tests
- Status: `DONE`
- [ ] Add app settings default tests for locale
- Status: `DONE`
- [ ] Add message catalog parity tests
- Status: `DONE`
- [ ] Add timestamp formatting tests
- Status: `DONE`
- [ ] Run `bun fmt`
- Status: `DONE`
- [ ] Run `bun lint`
- Status: `DONE`
- [ ] Run `bun typecheck`
- Status: `DONE`

Exit criteria:

- Catalog drift is caught by tests
- Locale behavior is covered by automated checks
- Required repository quality gates pass

## Shippable Stop

The first shippable stop is:

- Phase 1 complete
- Phase 2 complete
- Phase 4 verification complete

Phase 3 can follow later without blocking the first release if the app’s core localized surfaces are already coherent.

## Next Up

Immediate next implementation steps:

1. Migrate root route strings and root toasts.
2. Migrate Settings and its supporting components.
3. Migrate Onboarding and Mobile Pairing.
4. Continue with Phase 2 completion toward the first shippable localized stop.
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,7 @@ describe("ProviderCommandReactor", () => {
});

const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find(
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
);
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
expect(thread?.worktreePath).toBeNull();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DEFAULT_GIT_TEXT_GENERATION_MODEL,
EventId,
type OrchestrationEvent,
type ProjectId,
type ProviderModelOptions,
ProviderKind,
type ProviderStartOptions,
Expand Down Expand Up @@ -145,11 +146,11 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
function resolveSessionCwd(input: {
readonly thread: {
readonly id: ThreadId;
readonly projectId: string;
readonly projectId: ProjectId;
readonly worktreePath: string | null;
};
readonly projects: ReadonlyArray<{
readonly id: string;
readonly id: ProjectId;
readonly workspaceRoot: string;
}>;
}): {
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"oxfmt": "^0.42.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-intl": "^10.1.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AppSettingsSchema,
DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
DEFAULT_APP_LOCALE,
DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
getCustomModelOptionsByProvider,
Expand Down Expand Up @@ -258,6 +259,7 @@ describe("AppSettingsSchema", () => {
defaultThreadEnvMode: "worktree",
confirmThreadDelete: false,
enableAssistantStreaming: false,
locale: DEFAULT_APP_LOCALE,
sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER,
sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
timestampFormat: DEFAULT_TIMESTAMP_FORMAT,
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
normalizeModelSlug,
resolveSelectableModel,
} from "@okcode/shared/model";
import { APP_LOCALE_PREFERENCES } from "./i18n/types";
import { useLocalStorage } from "./hooks/useLocalStorage";
import { EnvMode } from "./components/BranchToolbar.logic";

Expand All @@ -21,6 +22,9 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]);
export type TimestampFormat = typeof TimestampFormat.Type;
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
export const AppLocale = Schema.Literals(APP_LOCALE_PREFERENCES);
export type AppLocale = typeof AppLocale.Type;
export const DEFAULT_APP_LOCALE: AppLocale = "system";
export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]);
export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type;
export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at";
Expand Down Expand Up @@ -64,6 +68,7 @@ export const AppSettingsSchema = Schema.Struct({
confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)),
diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)),
enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)),
locale: AppLocale.pipe(withDefaults(() => DEFAULT_APP_LOCALE)),
openLinksExternally: Schema.Boolean.pipe(withDefaults(() => false)),
sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(
withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER),
Expand Down
24 changes: 21 additions & 3 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../lib/diffFileReviewState";
import { resolveDiffThemeName } from "../lib/diffRendering";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { useI18n } from "../i18n/useI18n";
import { useStore } from "../store";
import { useAppSettings } from "../appSettings";
import { formatShortTimestamp } from "../timestampFormat";
Expand Down Expand Up @@ -349,6 +350,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const navigate = useNavigate();
const { resolvedTheme } = useTheme();
const { resolvedLocale } = useI18n();
const { settings } = useAppSettings();
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap);
Expand Down Expand Up @@ -641,14 +643,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
{selectedTurnId === null
? "All changes"
: selectedTurn?.turnId === latestSelectedTurnId
? `Latest • ${formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat)}`
? `Latest • ${formatShortTimestamp(
selectedTurn.completedAt,
settings.timestampFormat,
resolvedLocale,
)}`
: `Change ${
selectedTurn?.checkpointTurnCount ??
(selectedTurn
? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]
: null) ??
"?"
} • ${selectedTurn ? formatShortTimestamp(selectedTurn.completedAt, settings.timestampFormat) : ""}`}
} • ${
selectedTurn
? formatShortTimestamp(
selectedTurn.completedAt,
settings.timestampFormat,
resolvedLocale,
)
: ""
}`}
</SelectButton>
<SelectPopup>
<SelectItem value="all">All changes</SelectItem>
Expand All @@ -665,7 +679,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
}`}
</span>
<span className="text-muted-foreground text-xs">
{formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
{formatShortTimestamp(
summary.completedAt,
settings.timestampFormat,
resolvedLocale,
)}
</span>
</span>
</SelectItem>
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,7 @@ export const ChatHeader = memo(function ChatHeader({
/>
)}
{activeProjectName && hasCodeViewerTabs && (
<OpenInPicker
codeViewerOpen={codeViewerOpen}
onToggleCodeViewer={onToggleCodeViewer}
/>
<OpenInPicker codeViewerOpen={codeViewerOpen} onToggleCodeViewer={onToggleCodeViewer} />
)}
{!isMobileCompanion && activeProjectName && (
<GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />
Expand Down
Loading
Loading