From b13c269162e6c858acbce6cc792636cd4cb921a9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:00:46 -0600 Subject: [PATCH] wip(app): i18n --- packages/app/src/app.tsx | 28 +++++--- packages/app/src/context/language.tsx | 8 ++- packages/enterprise/src/app.tsx | 68 ++++++++++++++++++- packages/enterprise/src/entry-server.tsx | 48 ++++++++----- packages/ui/src/components/session-review.tsx | 12 ++-- packages/ui/src/components/session-turn.tsx | 52 ++++++++------ packages/ui/src/context/i18n.tsx | 5 +- specs/07-ui-i18n-audit.md | 10 +-- 8 files changed, 167 insertions(+), 64 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0ceebcce3f..c59cbe8988 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,6 +6,7 @@ import { Font } from "@opencode-ai/ui/font" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { CodeComponentProvider } from "@opencode-ai/ui/context/code" +import { I18nProvider } from "@opencode-ai/ui/context" import { Diff } from "@opencode-ai/ui/diff" import { Code } from "@opencode-ai/ui/code" import { ThemeProvider } from "@opencode-ai/ui/theme" @@ -21,7 +22,7 @@ import { FileProvider } from "@/context/file" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" -import { LanguageProvider } from "@/context/language" +import { LanguageProvider, useLanguage } from "@/context/language" import { Logo } from "@opencode-ai/ui/logo" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" @@ -33,6 +34,11 @@ const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
+function UiI18nBridge(props: ParentProps) { + const language = useLanguage() + return {props.children} +} + declare global { interface Window { __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string } @@ -45,15 +51,17 @@ export function AppBaseProviders(props: ParentProps) { - }> - - - - {props.children} - - - - + + }> + + + + {props.children} + + + + + diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 3178cb6b66..b04b4dfc46 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -5,10 +5,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" +import { dict as uiEn } from "@opencode-ai/ui/i18n/en" +import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" export type Locale = "en" | "zh" -type RawDictionary = typeof en +type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten const LOCALES: readonly Locale[] = ["en", "zh"] @@ -43,10 +45,10 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont setStore("locale", current) }) - const base = i18n.flatten(en) + const base = i18n.flatten({ ...en, ...uiEn }) const dict = createMemo(() => { if (locale() === "en") return base - return { ...base, ...i18n.flatten(zh) } + return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) } }) const t = i18n.translator(dict, i18n.resolveTemplate) diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx index 0fd3a009ca..1d408ac941 100644 --- a/packages/enterprise/src/app.tsx +++ b/packages/enterprise/src/app.tsx @@ -4,10 +4,72 @@ import { Font } from "@opencode-ai/ui/font" import { MetaProvider } from "@solidjs/meta" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DialogProvider } from "@opencode-ai/ui/context/dialog" -import { Suspense } from "solid-js" +import { I18nProvider, type UiI18nParams } from "@opencode-ai/ui/context" +import { dict as uiEn } from "@opencode-ai/ui/i18n/en" +import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" +import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js" +import { getRequestEvent } from "solid-js/web" import "./app.css" import { Favicon } from "@opencode-ai/ui/favicon" +function resolveTemplate(text: string, params?: UiI18nParams) { + if (!params) return text + return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => { + const key = String(rawKey) + const value = params[key] + return value === undefined ? "" : String(value) + }) +} + +function detectLocaleFromHeader(header: string | null | undefined) { + if (!header) return + for (const item of header.split(",")) { + const value = item.trim().split(";")[0]?.toLowerCase() + if (!value) continue + if (value.startsWith("zh")) return "zh" as const + if (value.startsWith("en")) return "en" as const + } +} + +function detectLocale() { + const event = getRequestEvent() + const header = event?.request.headers.get("accept-language") + const headerLocale = detectLocaleFromHeader(header) + if (headerLocale) return headerLocale + + if (typeof document === "object") { + const value = document.documentElement.lang?.toLowerCase() ?? "" + if (value.startsWith("zh")) return "zh" as const + if (value.startsWith("en")) return "en" as const + } + + if (typeof navigator === "object") { + const languages = navigator.languages?.length ? navigator.languages : [navigator.language] + for (const language of languages) { + if (!language) continue + if (language.toLowerCase().startsWith("zh")) return "zh" as const + } + } + + return "en" as const +} + +function UiI18nBridge(props: ParentProps) { + const locale = createMemo(() => detectLocale()) + const t = (key: keyof typeof uiEn, params?: UiI18nParams) => { + const value = locale() === "zh" ? uiZh[key] ?? uiEn[key] : uiEn[key] + const text = value ?? String(key) + return resolveTemplate(text, params) + } + + createEffect(() => { + if (typeof document !== "object") return + document.documentElement.lang = locale() + }) + + return {props.children} +} + export default function App() { return ( - {props.children} + + {props.children} + diff --git a/packages/enterprise/src/entry-server.tsx b/packages/enterprise/src/entry-server.tsx index 989c3c088b..b61448c956 100644 --- a/packages/enterprise/src/entry-server.tsx +++ b/packages/enterprise/src/entry-server.tsx @@ -1,23 +1,39 @@ // @refresh reload import { createHandler, StartServer } from "@solidjs/start/server" +import { getRequestEvent } from "solid-js/web" export default createHandler(() => ( ( - - - - - OpenCode - - - {assets} - - -
{children}
- {scripts} - - - )} + document={({ assets, children, scripts }) => { + const lang = (() => { + const event = getRequestEvent() + const header = event?.request.headers.get("accept-language") + if (!header) return "en" + for (const item of header.split(",")) { + const value = item.trim().split(";")[0]?.toLowerCase() + if (!value) continue + if (value.startsWith("zh")) return "zh" + if (value.startsWith("en")) return "en" + } + return "en" + })() + + return ( + + + + + OpenCode + + + {assets} + + +
{children}
+ {scripts} + + + ) + }} /> )) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index be5181a985..c4be87a0ba 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -6,6 +6,7 @@ import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { StickyAccordionHeader } from "./sticky-accordion-header" import { useDiffComponent } from "../context/diff" +import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { For, Match, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" @@ -32,6 +33,7 @@ export interface SessionReviewProps { } export const SessionReview = (props: SessionReviewProps) => { + const i18n = useI18n() const diffComponent = useDiffComponent() const [store, setStore] = createStore({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), @@ -68,21 +70,23 @@ export const SessionReview = (props: SessionReviewProps) => { [props.classes?.header ?? ""]: !!props.classes?.header, }} > -
Session changes
+
{i18n.t("ui.sessionReview.title")}
style} - label={(style) => (style === "unified" ? "Unified" : "Split")} + label={(style) => + i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split") + } onSelect={(style) => style && props.onDiffStyleChange?.(style)} /> {props.actions} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 21d00cf00a..737f4f41aa 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -9,6 +9,7 @@ import { } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" import { useDiffComponent } from "../context/diff" +import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Binary } from "@opencode-ai/util/binary" @@ -29,29 +30,31 @@ import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" import { createResizeObserver } from "@solid-primitives/resize-observer" -function computeStatusFromPart(part: PartType | undefined): string | undefined { +type Translator = (key: UiI18nKey, params?: UiI18nParams) => string + +function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined { if (!part) return undefined if (part.type === "tool") { switch (part.tool) { case "task": - return "Delegating work" + return t("ui.sessionTurn.status.delegating") case "todowrite": case "todoread": - return "Planning next steps" + return t("ui.sessionTurn.status.planning") case "read": - return "Gathering context" + return t("ui.sessionTurn.status.gatheringContext") case "list": case "grep": case "glob": - return "Searching the codebase" + return t("ui.sessionTurn.status.searchingCodebase") case "webfetch": - return "Searching the web" + return t("ui.sessionTurn.status.searchingWeb") case "edit": case "write": - return "Making edits" + return t("ui.sessionTurn.status.makingEdits") case "bash": - return "Running commands" + return t("ui.sessionTurn.status.runningCommands") default: return undefined } @@ -59,11 +62,11 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined { if (part.type === "reasoning") { const text = part.text ?? "" const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return `Thinking · ${match[1].trim()}` - return "Thinking" + if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() }) + return t("ui.sessionTurn.status.thinking") } if (part.type === "text") { - return "Gathering thoughts" + return t("ui.sessionTurn.status.gatheringThoughts") } return undefined } @@ -133,6 +136,7 @@ export function SessionTurn( } }>, ) { + const i18n = useI18n() const data = useData() const diffComponent = useDiffComponent() @@ -328,12 +332,12 @@ export function SessionTurn( const msgParts = data.store.part[msg.id] ?? emptyParts for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] - if (part) return computeStatusFromPart(part) + if (part) return computeStatusFromPart(part, i18n.t) } } } - return computeStatusFromPart(last) + return computeStatusFromPart(last, i18n.t) }) const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) @@ -368,7 +372,7 @@ export function SessionTurn( const interval = Interval.fromDateTimes(from, to) const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - return interval.toDuration(unit).normalize().toHuman({ + return interval.toDuration(unit).normalize().reconfigure({ locale: i18n.locale() }).toHuman({ notation: "compact", unitDisplay: "narrow", compactDisplay: "short", @@ -532,13 +536,18 @@ export function SessionTurn( })()} - · retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""} + · {i18n.t("ui.sessionTurn.retry.retrying")} + {store.retrySeconds > 0 + ? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds }) + : ""} (#{retry()?.attempt}) - {store.status ?? "Considering next steps"} - Hide steps - Show steps + + {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")} + + {i18n.t("ui.sessionTurn.steps.hide")} + {i18n.t("ui.sessionTurn.steps.show")} · {store.duration} @@ -580,7 +589,7 @@ export function SessionTurn(
-

Response

+

{i18n.t("ui.sessionTurn.summary.response")}

- Show more changes ( - {(data.store.session_diff?.[props.sessionID]?.length ?? 0) - store.diffLimit}) + {i18n.t("ui.sessionTurn.diff.showMore", { + count: (data.store.session_diff?.[props.sessionID]?.length ?? 0) - store.diffLimit, + })}
diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx index fd8b05d3cf..a2ff0f37b0 100644 --- a/packages/ui/src/context/i18n.tsx +++ b/packages/ui/src/context/i18n.tsx @@ -3,7 +3,7 @@ import { dict as en } from "../i18n/en" export type UiI18nKey = keyof typeof en -export type UiI18nParams = Record +export type UiI18nParams = Record export type UiI18n = { locale: Accessor @@ -15,8 +15,7 @@ function resolveTemplate(text: string, params?: UiI18nParams) { return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => { const key = String(rawKey) const value = params[key] - if (value === undefined || value === null) return "" - return String(value) + return value === undefined ? "" : String(value) }) } diff --git a/specs/07-ui-i18n-audit.md b/specs/07-ui-i18n-audit.md index 280818c00c..f6f67db738 100644 --- a/specs/07-ui-i18n-audit.md +++ b/specs/07-ui-i18n-audit.md @@ -122,12 +122,12 @@ Examples (non-exhaustive): ## Prioritized Implementation Plan -1. Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. -2. Add UI dictionaries (`packages/ui/src/i18n/en.ts`, `packages/ui/src/i18n/zh.ts`) + export them. -3. Wire `I18nProvider` into: +1. Completed (2026-01-20): Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. +2. Completed (2026-01-20): Add UI dictionaries (`packages/ui/src/i18n/en.ts`, `packages/ui/src/i18n/zh.ts`) + export them. +3. Completed (2026-01-20): Wire `I18nProvider` into: - `packages/app/src/app.tsx` - - `packages/enterprise/src/routes/share/[shareID].tsx` -4. Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`. + - `packages/enterprise/src/app.tsx` +4. Completed (2026-01-20): Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`. 5. Convert `packages/ui/src/components/message-part.tsx`. 6. Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy.