diff options
| author | Adam <[email protected]> | 2026-01-20 15:00:46 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-20 17:58:06 -0600 |
| commit | b13c269162e6c858acbce6cc792636cd4cb921a9 (patch) | |
| tree | d4e81f96109919ce41b36c175bd8da9aba5506ee | |
| parent | ef36af0e55d814dc80893af923886ccff40f46e5 (diff) | |
| download | opencode-b13c269162e6c858acbce6cc792636cd4cb921a9.tar.gz opencode-b13c269162e6c858acbce6cc792636cd4cb921a9.zip | |
wip(app): i18n
| -rw-r--r-- | packages/app/src/app.tsx | 28 | ||||
| -rw-r--r-- | packages/app/src/context/language.tsx | 8 | ||||
| -rw-r--r-- | packages/enterprise/src/app.tsx | 68 | ||||
| -rw-r--r-- | packages/enterprise/src/entry-server.tsx | 48 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 12 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 52 | ||||
| -rw-r--r-- | packages/ui/src/context/i18n.tsx | 5 | ||||
| -rw-r--r-- | 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 0ceebcce3..c59cbe898 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 = () => <div class="size-full" /> +function UiI18nBridge(props: ParentProps) { + const language = useLanguage() + return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider> +} + declare global { interface Window { __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string } @@ -45,15 +51,17 @@ export function AppBaseProviders(props: ParentProps) { <Font /> <ThemeProvider> <LanguageProvider> - <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> - <DialogProvider> - <MarkedProvider> - <DiffComponentProvider component={Diff}> - <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider> - </DiffComponentProvider> - </MarkedProvider> - </DialogProvider> - </ErrorBoundary> + <UiI18nBridge> + <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> + <DialogProvider> + <MarkedProvider> + <DiffComponentProvider component={Diff}> + <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider> + </DiffComponentProvider> + </MarkedProvider> + </DialogProvider> + </ErrorBoundary> + </UiI18nBridge> </LanguageProvider> </ThemeProvider> </MetaProvider> diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 3178cb6b6..b04b4dfc4 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<RawDictionary> 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<Dictionary>(() => { 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 0fd3a009c..1d408ac94 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 <I18nProvider value={{ locale, t }}>{props.children}</I18nProvider> +} + export default function App() { return ( <Router @@ -17,7 +79,9 @@ export default function App() { <MarkedProvider> <Favicon /> <Font /> - <Suspense>{props.children}</Suspense> + <UiI18nBridge> + <Suspense>{props.children}</Suspense> + </UiI18nBridge> </MarkedProvider> </DialogProvider> </MetaProvider> diff --git a/packages/enterprise/src/entry-server.tsx b/packages/enterprise/src/entry-server.tsx index 989c3c088..b61448c95 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(() => ( <StartServer - document={({ assets, children, scripts }) => ( - <html lang="en"> - <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title>OpenCode</title> - <meta name="theme-color" content="#F8F7F7" /> - <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> - {assets} - </head> - <body class="antialiased overscroll-none text-12-regular"> - <div id="app">{children}</div> - {scripts} - </body> - </html> - )} + 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 ( + <html lang={lang}> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>OpenCode</title> + <meta name="theme-color" content="#F8F7F7" /> + <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> + {assets} + </head> + <body class="antialiased overscroll-none text-12-regular"> + <div id="app">{children}</div> + {scripts} + </body> + </html> + ) + }} /> )) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index be5181a98..c4be87a0b 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, }} > - <div data-slot="session-review-title">Session changes</div> + <div data-slot="session-review-title">{i18n.t("ui.sessionReview.title")}</div> <div data-slot="session-review-actions"> <Show when={props.onDiffStyleChange}> <RadioGroup options={["unified", "split"] as const} current={diffStyle()} value={(style) => 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)} /> </Show> <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}> <Switch> - <Match when={open().length > 0}>Collapse all</Match> - <Match when={true}>Expand all</Match> + <Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match> + <Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match> </Switch> </Button> {props.actions} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 21d00cf00..737f4f41a 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( })()} </span> <span data-slot="session-turn-retry-seconds"> - · 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 }) + : ""} </span> <span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span> </Match> - <Match when={working()}>{store.status ?? "Considering next steps"}</Match> - <Match when={props.stepsExpanded}>Hide steps</Match> - <Match when={!props.stepsExpanded}>Show steps</Match> + <Match when={working()}> + {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")} + </Match> + <Match when={props.stepsExpanded}>{i18n.t("ui.sessionTurn.steps.hide")}</Match> + <Match when={!props.stepsExpanded}>{i18n.t("ui.sessionTurn.steps.show")}</Match> </Switch> <span>·</span> <span>{store.duration}</span> @@ -580,7 +589,7 @@ export function SessionTurn( <Show when={!working() && (response() || hasDiffs())}> <div data-slot="session-turn-summary-section"> <div data-slot="session-turn-summary-header"> - <h2 data-slot="session-turn-summary-title">Response</h2> + <h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2> <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} @@ -657,8 +666,9 @@ export function SessionTurn( }) }} > - 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, + })} </Button> </Show> </div> diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx index fd8b05d3c..a2ff0f37b 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<string, string | number | boolean | null | undefined> +export type UiI18nParams = Record<string, string | number | boolean> export type UiI18n = { locale: Accessor<string> @@ -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 280818c00..f6f67db73 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. |
