Adam 7 месяцев назад
Родитель
Сommit
b13c269162

+ 18 - 10
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>

+ 5 - 3
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)

+ 66 - 2
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>

+ 32 - 16
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>
+      )
+    }}
   />
 ))

+ 8 - 4
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}

+ 31 - 21
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>

+ 2 - 3
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)
   })
 }
 

+ 5 - 5
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.