-
Notifications
You must be signed in to change notification settings - Fork 7.1k
feat(app): I18n support (only chinese currently) #9190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
233278d to
f945b33
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
2488744 to
cea1607
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| return ( | ||
| <ServerProvider defaultUrl={defaultServerUrl()}> | ||
| <ServerKey> | ||
| <LanguageProvider> |
Copilot
AI
Jan 18, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will do after review done.
cea1607 to
2f57db6
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
|
@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. |
4a6a7a7 to
7f9d107
Compare
741f54f to
0f7384d
Compare
…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>
0f7384d to
23c4fb7
Compare
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