Uh oh!
There was an error while loading. Please reload this page.
feat(app): I18n support (only chinese currently) - #9190
Conversation
Hey! Your PR title Please update it to start with one of:
Where See CONTRIBUTING.md for details. |
The following comment was made by an LLM, it may be inaccurate: Potential Duplicate PRs Found:
Note: PR #7290 appears to be the most directly related, as it has the same feature (i18n support) and possibly the same implementation intent as the current PR #9190. |
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
There was a problem hiding this comment.
Pull request overview
This PR implements internationalization (i18n) support for the OpenCode app, currently supporting English and Chinese languages. The implementation adds a language context provider, translation files for both languages, and updates all hardcoded UI strings throughout the application to use translation keys.
Changes:
- Added LanguageProvider context with translation loading and parameter interpolation
- Created comprehensive translation files (en.json, zh.json) with 300+ translation keys
- Updated all UI components and pages to use translation keys instead of hardcoded strings
- Added language switching commands with keyboard shortcut (mod+shift+l)
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/app/src/context/language.tsx | New language context with translation function and localStorage persistence |
| packages/app/src/locales/en.json | English translation file with all UI strings |
| packages/app/src/locales/zh.json | Chinese translation file with all UI strings |
| packages/app/tsconfig.json | Enabled JSON module imports for translation files |
| packages/app/src/app.tsx | Integrated LanguageProvider into app component tree |
| packages/app/src/pages/*.tsx | Updated pages to use translation keys |
| packages/app/src/components/**/*.tsx | Updated all components to use translation keys |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
233278d to
f945b33CompareThere was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
2488744 to
cea1607CompareThere was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
| @@ -78,6 +79,7 @@ export function AppInterface(props: { defaultUrl?: string }) { | |||
| return ( | |||
| <ServerProvider defaultUrl={defaultServerUrl()}> | |||
| <ServerKey> | |||
| <LanguageProvider> | |||
There was a problem hiding this comment.
The indentation for the LanguageProvider opening tag is inconsistent. It should be aligned with the sibling provider elements.
There was a problem hiding this comment.
Will do after review done.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
packages/app/src/pages/session.tsx:727
- The
command.registercallback accesseslanguagecontext inside an arrow function that's called reactively. Since this callback returns command definitions each time, and these commands are likely registered during component initialization, this could cause the commands to be re-registered every time the language changes. Consider whether commands should be re-registered on language change, and if so, ensure this behavior is intentional and properly handled. If commands should update reactively with language changes, this is correct. Otherwise, consider memoizing or using a different pattern.
command.register(() => [
{
id: "session.new",
title: language.t("session.new"),
category: language.t("command.category.session"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
{
id: "file.open",
title: language.t("command.title.openFile"),
description: language.t("command.description.openFile"),
category: language.t("command.category.file"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile />),
},
{
id: "terminal.toggle",
title: language.t("terminal.toggle"),
description: "",
category: language.t("command.category.view"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
},
{
id: "review.toggle",
title: language.t("review.toggle"),
description: "",
category: language.t("command.category.view"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
},
{
id: "terminal.new",
title: language.t("terminal.new"),
description: language.t("command.description.newTerminal"),
category: language.t("command.category.terminal"),
keybind: "ctrl+shift+`",
onSelect: () => terminal.new(),
},
{
id: "steps.toggle",
title: language.t("command.title.toggleSteps"),
description: language.t("command.description.toggleSteps"),
category: language.t("command.category.view"),
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = activeMessage()
if (!msg) return
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
{
id: "message.previous",
title: language.t("command.title.previousMessage"),
description: language.t("command.description.previousMessage"),
category: language.t("command.category.session"),
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
},
{
id: "message.next",
title: language.t("command.title.nextMessage"),
description: language.t("command.description.nextMessage"),
category: language.t("command.category.session"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
},
{
id: "model.choose",
title: language.t("command.title.chooseModel"),
description: language.t("command.description.chooseModel"),
category: language.t("command.category.model"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
},
{
id: "mcp.toggle",
title: language.t("command.title.toggleMcp"),
description: language.t("command.description.toggleMcp"),
category: language.t("command.category.mcp"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
},
{
id: "agent.cycle",
title: language.t("command.title.cycleAgent"),
description: language.t("command.description.cycleAgent"),
category: language.t("command.category.agent"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
},
{
id: "agent.cycle.reverse",
title: language.t("command.title.cycleAgentBack"),
description: language.t("command.description.cycleAgentBack"),
category: language.t("command.category.agent"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
},
{
id: "model.variant.cycle",
title: language.t("command.title.cycleThinking"),
description: language.t("command.description.cycleThinking"),
category: language.t("command.category.model"),
keybind: "shift+mod+t",
onSelect: () => {
local.model.variant.cycle()
showToast({
title: language.t("command.toast.thinkingEffortChanged"),
description: language.t("command.toast.thinkingEffortDescription", {
effort: local.model.variant.current() ?? language.t("model.defaultVariant"),
}),
})
},
},
{
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.title.stopAutoAcceptEdits")
: language.t("command.title.autoAcceptEdits"),
category: language.t("command.category.permissions"),
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("command.toast.autoAcceptOnTitle")
: language.t("command.toast.autoAcceptOffTitle"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("command.toast.autoAcceptOnDescription")
: language.t("command.toast.autoAcceptOffDescription"),
})
},
},
{
id: "session.undo",
title: language.t("command.title.undo"),
description: language.t("command.description.undo"),
category: language.t("command.category.session"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
// Find the last user message that's not already reverted
const message = userMessages().findLast((x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = userMessages().findLast((x) => x.id < message.id)
setActiveMessage(priorMessage)
},
},
{
id: "session.redo",
title: language.t("command.title.redo"),
description: language.t("command.description.redo"),
category: language.t("command.category.session"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
// Full unrevert - restore all messages and navigate to last
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
// Navigate to the last message (the one that was at the revert point)
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
// Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
// Navigate to the message before the new revert point
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
},
{
id: "session.compact",
title: language.t("command.title.compactSession"),
description: language.t("command.description.compactSession"),
category: language.t("command.category.session"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("command.toast.noModelTitle"),
description: language.t("command.toast.noModelDescription"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
},
{
id: "session.fork",
title: language.t("command.title.forkFromMessage"),
description: language.t("command.description.forkFromMessage"),
category: language.t("command.category.session"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: language.t("session.shareCommandTitle"),
description: language.t("session.shareCommandDescription"),
category: language.t("command.category.session"),
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: language.t("session.shareCopyFailedTitle"),
variant: "error",
}),
)
})
.then(() =>
showToast({
title: language.t("session.shareSuccessTitle"),
description: language.t("session.shareSuccessDescription"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("session.shareFailedTitle"),
description: language.t("session.shareFailedDescription"),
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: language.t("session.unshareCommandTitle"),
description: language.t("session.unshareCommandDescription"),
category: language.t("command.category.session"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: language.t("session.unshareSuccessTitle"),
description: language.t("session.unshareSuccessDescription"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("session.unshareFailedTitle"),
description: language.t("session.unshareFailedDescription"),
variant: "error",
}),
)
},
},
]
: []),
])
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
adamdotdevin
commented
Jan 18, 2026
@Eric-Guo mind adding some screenshots? What is the mechanism for switching langauges? Is there any auto-detection? |
screenshot add, you can using The translation is relative complete. No any auto-detection and no other 4 languages😎, but I think it can be add later without too much conflict here. |
741f54f to
0f7384dCompare…y switching the provider to load from the locale JSON files, then replaced many user‑facing strings with language.t keys and added matching entries in the locale files.
…labels to translations so sorting no longer depends on localized strings.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Eric-Guo
commented
Jan 21, 2026
do in dev already. |
Implement / Closes: #7170, #6326, #2357
app/desktop only, console/terminal suggest don't do i18n support and not include in this PR
Cmd+Shift+lto switch the language orCmd+Pand type 'language', no UI button added yet.Windows:
MacOS