wip(app): i18n

This commit is contained in:
Adam 2026-01-20 15:00:46 -06:00
parent ef36af0e55
commit b13c269162
8 changed files with 167 additions and 64 deletions

View File

@ -6,6 +6,7 @@ import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code" import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff" import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
@ -21,7 +22,7 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
import { LanguageProvider } from "@/context/language" import { LanguageProvider, useLanguage } from "@/context/language"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
@ -33,6 +34,11 @@ const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> 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 { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string } __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
@ -45,15 +51,17 @@ export function AppBaseProviders(props: ParentProps) {
<Font /> <Font />
<ThemeProvider> <ThemeProvider>
<LanguageProvider> <LanguageProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <UiI18nBridge>
<DialogProvider> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<MarkedProvider> <DialogProvider>
<DiffComponentProvider component={Diff}> <MarkedProvider>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider> <DiffComponentProvider component={Diff}>
</DiffComponentProvider> <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</MarkedProvider> </DiffComponentProvider>
</DialogProvider> </MarkedProvider>
</ErrorBoundary> </DialogProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider> </LanguageProvider>
</ThemeProvider> </ThemeProvider>
</MetaProvider> </MetaProvider>

View File

@ -5,10 +5,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" 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" export type Locale = "en" | "zh"
type RawDictionary = typeof en type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
const LOCALES: readonly Locale[] = ["en", "zh"] const LOCALES: readonly Locale[] = ["en", "zh"]
@ -43,10 +45,10 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
setStore("locale", current) setStore("locale", current)
}) })
const base = i18n.flatten(en) const base = i18n.flatten({ ...en, ...uiEn })
const dict = createMemo<Dictionary>(() => { const dict = createMemo<Dictionary>(() => {
if (locale() === "en") return base if (locale() === "en") return base
return { ...base, ...i18n.flatten(zh) } return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
}) })
const t = i18n.translator(dict, i18n.resolveTemplate) const t = i18n.translator(dict, i18n.resolveTemplate)

View File

@ -4,10 +4,72 @@ import { Font } from "@opencode-ai/ui/font"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" 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 "./app.css"
import { Favicon } from "@opencode-ai/ui/favicon" 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() { export default function App() {
return ( return (
<Router <Router
@ -17,7 +79,9 @@ export default function App() {
<MarkedProvider> <MarkedProvider>
<Favicon /> <Favicon />
<Font /> <Font />
<Suspense>{props.children}</Suspense> <UiI18nBridge>
<Suspense>{props.children}</Suspense>
</UiI18nBridge>
</MarkedProvider> </MarkedProvider>
</DialogProvider> </DialogProvider>
</MetaProvider> </MetaProvider>

View File

@ -1,23 +1,39 @@
// @refresh reload // @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server" import { createHandler, StartServer } from "@solidjs/start/server"
import { getRequestEvent } from "solid-js/web"
export default createHandler(() => ( export default createHandler(() => (
<StartServer <StartServer
document={({ assets, children, scripts }) => ( document={({ assets, children, scripts }) => {
<html lang="en"> const lang = (() => {
<head> const event = getRequestEvent()
<meta charset="utf-8" /> const header = event?.request.headers.get("accept-language")
<meta name="viewport" content="width=device-width, initial-scale=1" /> if (!header) return "en"
<title>OpenCode</title> for (const item of header.split(",")) {
<meta name="theme-color" content="#F8F7F7" /> const value = item.trim().split(";")[0]?.toLowerCase()
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> if (!value) continue
{assets} if (value.startsWith("zh")) return "zh"
</head> if (value.startsWith("en")) return "en"
<body class="antialiased overscroll-none text-12-regular"> }
<div id="app">{children}</div> return "en"
{scripts} })()
</body>
</html> 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>
)
}}
/> />
)) ))

View File

@ -6,6 +6,7 @@ import { FileIcon } from "./file-icon"
import { Icon } from "./icon" import { Icon } from "./icon"
import { StickyAccordionHeader } from "./sticky-accordion-header" import { StickyAccordionHeader } from "./sticky-accordion-header"
import { useDiffComponent } from "../context/diff" import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { For, Match, Show, Switch, type JSX } from "solid-js" import { For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@ -32,6 +33,7 @@ export interface SessionReviewProps {
} }
export const SessionReview = (props: SessionReviewProps) => { export const SessionReview = (props: SessionReviewProps) => {
const i18n = useI18n()
const diffComponent = useDiffComponent() const diffComponent = useDiffComponent()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), 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, [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"> <div data-slot="session-review-actions">
<Show when={props.onDiffStyleChange}> <Show when={props.onDiffStyleChange}>
<RadioGroup <RadioGroup
options={["unified", "split"] as const} options={["unified", "split"] as const}
current={diffStyle()} current={diffStyle()}
value={(style) => style} 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)} onSelect={(style) => style && props.onDiffStyleChange?.(style)}
/> />
</Show> </Show>
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}> <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch> <Switch>
<Match when={open().length > 0}>Collapse all</Match> <Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
<Match when={true}>Expand all</Match> <Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
</Switch> </Switch>
</Button> </Button>
{props.actions} {props.actions}

View File

@ -9,6 +9,7 @@ import {
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context" import { useData } from "../context"
import { useDiffComponent } from "../context/diff" import { useDiffComponent } from "../context/diff"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
@ -29,29 +30,31 @@ import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks" import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer" 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) return undefined
if (part.type === "tool") { if (part.type === "tool") {
switch (part.tool) { switch (part.tool) {
case "task": case "task":
return "Delegating work" return t("ui.sessionTurn.status.delegating")
case "todowrite": case "todowrite":
case "todoread": case "todoread":
return "Planning next steps" return t("ui.sessionTurn.status.planning")
case "read": case "read":
return "Gathering context" return t("ui.sessionTurn.status.gatheringContext")
case "list": case "list":
case "grep": case "grep":
case "glob": case "glob":
return "Searching the codebase" return t("ui.sessionTurn.status.searchingCodebase")
case "webfetch": case "webfetch":
return "Searching the web" return t("ui.sessionTurn.status.searchingWeb")
case "edit": case "edit":
case "write": case "write":
return "Making edits" return t("ui.sessionTurn.status.makingEdits")
case "bash": case "bash":
return "Running commands" return t("ui.sessionTurn.status.runningCommands")
default: default:
return undefined return undefined
} }
@ -59,11 +62,11 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined {
if (part.type === "reasoning") { if (part.type === "reasoning") {
const text = part.text ?? "" const text = part.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/) const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return `Thinking · ${match[1].trim()}` if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() })
return "Thinking" return t("ui.sessionTurn.status.thinking")
} }
if (part.type === "text") { if (part.type === "text") {
return "Gathering thoughts" return t("ui.sessionTurn.status.gatheringThoughts")
} }
return undefined return undefined
} }
@ -133,6 +136,7 @@ export function SessionTurn(
} }
}>, }>,
) { ) {
const i18n = useI18n()
const data = useData() const data = useData()
const diffComponent = useDiffComponent() const diffComponent = useDiffComponent()
@ -328,12 +332,12 @@ export function SessionTurn(
const msgParts = data.store.part[msg.id] ?? emptyParts const msgParts = data.store.part[msg.id] ?? emptyParts
for (let pi = msgParts.length - 1; pi >= 0; pi--) { for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[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) const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
@ -368,7 +372,7 @@ export function SessionTurn(
const interval = Interval.fromDateTimes(from, to) const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] 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", notation: "compact",
unitDisplay: "narrow", unitDisplay: "narrow",
compactDisplay: "short", compactDisplay: "short",
@ -532,13 +536,18 @@ export function SessionTurn(
})()} })()}
</span> </span>
<span data-slot="session-turn-retry-seconds"> <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>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span> <span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match> </Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match> <Match when={working()}>
<Match when={props.stepsExpanded}>Hide steps</Match> {store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")}
<Match when={!props.stepsExpanded}>Show steps</Match> </Match>
<Match when={props.stepsExpanded}>{i18n.t("ui.sessionTurn.steps.hide")}</Match>
<Match when={!props.stepsExpanded}>{i18n.t("ui.sessionTurn.steps.show")}</Match>
</Switch> </Switch>
<span>·</span> <span>·</span>
<span>{store.duration}</span> <span>{store.duration}</span>
@ -580,7 +589,7 @@ export function SessionTurn(
<Show when={!working() && (response() || hasDiffs())}> <Show when={!working() && (response() || hasDiffs())}>
<div data-slot="session-turn-summary-section"> <div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header"> <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 <Markdown
data-slot="session-turn-markdown" data-slot="session-turn-markdown"
data-diffs={hasDiffs()} data-diffs={hasDiffs()}
@ -657,8 +666,9 @@ export function SessionTurn(
}) })
}} }}
> >
Show more changes ( {i18n.t("ui.sessionTurn.diff.showMore", {
{(data.store.session_diff?.[props.sessionID]?.length ?? 0) - store.diffLimit}) count: (data.store.session_diff?.[props.sessionID]?.length ?? 0) - store.diffLimit,
})}
</Button> </Button>
</Show> </Show>
</div> </div>

View File

@ -3,7 +3,7 @@ import { dict as en } from "../i18n/en"
export type UiI18nKey = keyof typeof 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 = { export type UiI18n = {
locale: Accessor<string> locale: Accessor<string>
@ -15,8 +15,7 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => { return text.replace(/{{\s*([^}]+?)\s*}}/g, (_, rawKey) => {
const key = String(rawKey) const key = String(rawKey)
const value = params[key] const value = params[key]
if (value === undefined || value === null) return "" return value === undefined ? "" : String(value)
return String(value)
}) })
} }

View File

@ -122,12 +122,12 @@ Examples (non-exhaustive):
## Prioritized Implementation Plan ## Prioritized Implementation Plan
1. Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. 1. Completed (2026-01-20): 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. 2. Completed (2026-01-20): Add UI dictionaries (`packages/ui/src/i18n/en.ts`, `packages/ui/src/i18n/zh.ts`) + export them.
3. Wire `I18nProvider` into: 3. Completed (2026-01-20): Wire `I18nProvider` into:
- `packages/app/src/app.tsx` - `packages/app/src/app.tsx`
- `packages/enterprise/src/routes/share/[shareID].tsx` - `packages/enterprise/src/app.tsx`
4. Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`. 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`. 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. 6. Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy.