diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8f9104bd85..0ceebcce3f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -44,15 +44,17 @@ export function AppBaseProviders(props: ParentProps) { - }> - - - - {props.children} - - - - + + }> + + + + {props.children} + + + + + ) @@ -85,17 +87,15 @@ export function AppInterface(props: { defaultUrl?: string }) { ( - - - - - - {props.children} - - - - - + + + + + {props.children} + + + + )} > diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index c4c52fc4d7..17782f5ab8 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -61,7 +61,10 @@ export const DialogFork: Component = () => { if (!sessionID) return const parts = sync.data.part[item.id] ?? [] - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + const restored = extractPromptFromParts(parts, { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }) dialog.close() diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ba42ffdd6e..cdb299c79d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -38,8 +38,6 @@ const ModelList: Component<{ sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 const aProvider = a.items[0].provider.id const bProvider = b.items[0].provider.id if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx index ac3a399979..dab92920ec 100644 --- a/packages/app/src/components/session-lsp-indicator.tsx +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -1,9 +1,11 @@ import { createMemo, Show } from "solid-js" import { useSync } from "@/context/sync" +import { useLanguage } from "@/context/language" import { Tooltip } from "@opencode-ai/ui/tooltip" export function SessionLspIndicator() { const sync = useSync() + const language = useLanguage() const lspStats = createMemo(() => { const lsp = sync.data.lsp ?? [] @@ -15,7 +17,7 @@ export function SessionLspIndicator() { const tooltipContent = createMemo(() => { const lsp = sync.data.lsp ?? [] - if (lsp.length === 0) return "No LSP servers" + if (lsp.length === 0) return language.t("lsp.tooltip.none") return lsp.map((s) => s.name).join(", ") }) @@ -30,7 +32,9 @@ export function SessionLspIndicator() { "bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0, }} /> - {lspStats().connected} LSP + + {language.t("lsp.label.connected", { count: lspStats().connected })} + diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 595ff9d6f8..a4a434b05a 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -7,6 +7,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" import { getFilename } from "@opencode-ai/util/path" import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return ( @@ -25,6 +26,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() + const language = useLanguage() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) return ( @@ -34,7 +36,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v + props.onTabClose(props.tab)} /> } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 272f851449..bbfdf895db 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -6,11 +6,13 @@ import { useTheme } from "@opencode-ai/ui/theme" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" export function Titlebar() { const layout = useLayout() const platform = usePlatform() const command = useCommand() + const language = useLanguage() const theme = useTheme() const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") @@ -93,7 +95,7 @@ export function Titlebar() { sync.data.path.directory) @@ -323,7 +325,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ ) showToast({ variant: "error", - title: "Failed to load file", + title: language.t("toast.file.loadFailed.title"), description: e.message, }) }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 14dc6e0545..1738cb38bc 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -41,6 +41,7 @@ import { import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" import { usePlatform } from "./platform" +import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" type State = { @@ -95,6 +96,7 @@ type ChildOptions = { function createGlobalSync() { const globalSDK = useGlobalSDK() const platform = usePlatform() + const language = useLanguage() const owner = getOwner() if (!owner) throw new Error("GlobalSync must be created within owner") const vcsCache = new Map() @@ -232,7 +234,7 @@ function createGlobalSync() { .catch((err) => { console.error("Failed to load sessions", err) const project = getFilename(directory) - showToast({ title: `Failed to load sessions for ${project}`, description: err.message }) + showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message }) }) sessionLoads.set(directory, promise) @@ -658,7 +660,7 @@ function createGlobalSync() { if (!health?.healthy) { setGlobalStore( "error", - new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`), + new Error(language.t("error.globalSync.connectFailed", { url: globalSDK.url })), ) return } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 2ed57234f2..64bfa838dd 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -10,6 +10,7 @@ import { useProviders } from "@/hooks/use-providers" import { DateTime } from "luxon" import { Persist, persisted } from "@/utils/persist" import { showToast } from "@opencode-ai/ui/toast" +import { useLanguage } from "@/context/language" export type LocalFile = FileNode & Partial<{ @@ -42,6 +43,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useSDK() const sync = useSync() const providers = useProviders() + const language = useLanguage() function isModelValid(model: ModelKey) { const provider = providers.all().find((x) => x.id === model.providerID) @@ -409,7 +411,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch((e) => { showToast({ variant: "error", - title: "Failed to load file", + title: language.t("toast.file.loadFailed.title"), description: e.message, }) }) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 8b10885194..579c36999c 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { Binary } from "@opencode-ai/util/binary" import { base64Encode } from "@opencode-ai/util/encode" @@ -47,6 +48,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const globalSync = useGlobalSync() const platform = usePlatform() const settings = useSettings() + const language = useLanguage() const [store, setStore, _, ready] = persisted( Persist.global("notification", ["notification.v1"]), @@ -94,9 +96,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const href = `/${base64Encode(directory)}/session/${sessionID}` if (settings.notifications.agent()) { - void platform.notify("Response ready", session?.title ?? sessionID, href) + void platform.notify(language.t("notification.session.responseReady.title"), session?.title ?? sessionID, href) } - break } case "session.error": { @@ -115,13 +116,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi session: sessionID ?? "global", error, }) - - const description = session?.title ?? (typeof error === "string" ? error : "An error occurred") + const description = + session?.title ?? (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` if (settings.notifications.errors()) { - void platform.notify("Session error", description, href) + void platform.notify(language.t("notification.session.error.title"), description, href) } - break } } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 97ab9e0bfc..34fec61774 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -139,6 +139,7 @@ export const dict = { "common.save": "Save", "common.saving": "Saving...", "common.default": "Default", + "common.attachment": "attachment", "prompt.placeholder.shell": "Enter shell command...", "prompt.placeholder.normal": "Ask anything... \"{{example}}\"", @@ -278,6 +279,8 @@ export const dict = { "toast.model.none.title": "No model selected", "toast.model.none.description": "Connect a provider to summarize this session", + "toast.file.loadFailed.title": "Failed to load file", + "toast.session.share.copyFailed.title": "Failed to copy URL to clipboard", "toast.session.share.success.title": "Session shared", "toast.session.share.success.description": "Share URL copied to clipboard!", @@ -289,6 +292,8 @@ export const dict = { "toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session", + "toast.session.listFailed.title": "Failed to load sessions for {{project}}", + "toast.update.title": "Update available", "toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.", "toast.update.action.installRestart": "Install and restart", @@ -305,6 +310,8 @@ export const dict = { "error.page.report.discord": "on Discord", "error.page.version": "Version: {{version}}", + "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", + "error.chain.unknown": "Unknown error", "error.chain.causedBy": "Caused by:", "error.chain.apiError": "API error", @@ -332,6 +339,10 @@ export const dict = { "notification.question.description": "{{sessionTitle}} in {{projectName}} has a question", "notification.action.goToSession": "Go to session", + "notification.session.responseReady.title": "Response ready", + "notification.session.error.title": "Session error", + "notification.session.error.fallbackDescription": "An error occurred", + "home.recentProjects": "Recent projects", "home.empty.title": "No recent projects", "home.empty.description": "Get started by opening a local project", @@ -368,6 +379,9 @@ export const dict = { "session.share.copy.copied": "Copied", "session.share.copy.copyLink": "Copy link", + "lsp.tooltip.none": "No LSP servers", + "lsp.label.connected": "{{count}} LSP", + "prompt.loading": "Loading prompt...", "terminal.loading": "Loading terminal...", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 014fa53531..7ae62350dc 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -138,6 +138,7 @@ export const dict = { "common.save": "保存", "common.saving": "保存中...", "common.default": "默认", + "common.attachment": "附件", "prompt.placeholder.shell": "输入 shell 命令...", "prompt.placeholder.normal": "随便问点什么... \"{{example}}\"", @@ -277,6 +278,8 @@ export const dict = { "toast.model.none.title": "未选择模型", "toast.model.none.description": "请先连接提供商以总结此会话", + "toast.file.loadFailed.title": "加载文件失败", + "toast.session.share.copyFailed.title": "无法复制链接到剪贴板", "toast.session.share.success.title": "会话已分享", "toast.session.share.success.description": "分享链接已复制到剪贴板", @@ -288,6 +291,8 @@ export const dict = { "toast.session.unshare.failed.title": "取消分享失败", "toast.session.unshare.failed.description": "取消分享会话时发生错误", + "toast.session.listFailed.title": "无法加载 {{project}} 的会话", + "toast.update.title": "有可用更新", "toast.update.description": "OpenCode 有新版本 ({{version}}) 可安装。", "toast.update.action.installRestart": "安装并重启", @@ -304,6 +309,8 @@ export const dict = { "error.page.report.discord": "在 Discord 上", "error.page.version": "版本: {{version}}", + "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?", + "error.chain.unknown": "未知错误", "error.chain.causedBy": "原因:", "error.chain.apiError": "API 错误", @@ -329,6 +336,10 @@ export const dict = { "notification.question.description": "{{sessionTitle}}({{projectName}})有一个问题", "notification.action.goToSession": "前往会话", + "notification.session.responseReady.title": "回复已就绪", + "notification.session.error.title": "会话错误", + "notification.session.error.fallbackDescription": "发生错误", + "home.recentProjects": "最近项目", "home.empty.title": "没有最近项目", "home.empty.description": "通过打开本地项目开始使用", @@ -365,6 +376,9 @@ export const dict = { "session.share.copy.copied": "已复制", "session.share.copy.copyLink": "复制链接", + "lsp.tooltip.none": "没有 LSP 服务器", + "lsp.label.connected": "{{count}} LSP", + "prompt.loading": "正在加载提示...", "terminal.loading": "正在加载终端...", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f98b02e7e2..7733784f9a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -597,7 +597,10 @@ export default function Page() { // Restore the prompt from the reverted message const parts = sync.data.part[message.id] if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + const restored = extractPromptFromParts(parts, { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }) prompt.set(restored) } // Navigate to the message before the reverted one (which will be the new last visible message) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 5d9edfed10..35aec0071a 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -53,10 +53,11 @@ function textPartValue(parts: Part[]) { * Extract prompt content from message parts for restoring into the prompt input. * This is used by undo to restore the original user prompt. */ -export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt { +export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt { const textPart = textPartValue(parts) const text = textPart?.text ?? "" const directory = opts?.directory + const attachmentName = opts?.attachmentName ?? "attachment" const toRelative = (path: string) => { if (!directory) return path @@ -104,7 +105,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin images.push({ type: "image", id: filePart.id, - filename: filePart.filename ?? "attachment", + filename: filePart.filename ?? attachmentName, mime: filePart.mime, dataUrl: filePart.url, }) diff --git a/specs/06-app-i18n-audit.md b/specs/06-app-i18n-audit.md index a3b28decad..42d0c0c8dd 100644 --- a/specs/06-app-i18n-audit.md +++ b/specs/06-app-i18n-audit.md @@ -9,8 +9,8 @@ This report documents the remaining user-facing strings in `packages/app/src` th ## Current State - The app uses `useLanguage().t("...")` with dictionaries in `packages/app/src/i18n/en.ts` and `packages/app/src/i18n/zh.ts`. -- Recent progress (already translated): `packages/app/src/pages/home.tsx`, `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/session.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/app/src/components/dialog-connect-provider.tsx`, `packages/app/src/components/session/session-header.tsx`, `packages/app/src/pages/error.tsx`, `packages/app/src/components/session/session-new-view.tsx`, `packages/app/src/components/session-context-usage.tsx`, `packages/app/src/components/session/session-context-tab.tsx` (plus new keys added in both dictionaries). -- Dictionary parity check: `en.ts` and `zh.ts` currently contain the same key set (362 keys each; no missing or extra keys). +- Recent progress (already translated): `packages/app/src/pages/home.tsx`, `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/session.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/app/src/components/dialog-connect-provider.tsx`, `packages/app/src/components/session/session-header.tsx`, `packages/app/src/pages/error.tsx`, `packages/app/src/components/session/session-new-view.tsx`, `packages/app/src/components/session-context-usage.tsx`, `packages/app/src/components/session/session-context-tab.tsx`, `packages/app/src/components/session-lsp-indicator.tsx`, `packages/app/src/components/session/session-sortable-tab.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/components/dialog-select-model.tsx`, `packages/app/src/context/notification.tsx`, `packages/app/src/context/global-sync.tsx`, `packages/app/src/context/file.tsx`, `packages/app/src/context/local.tsx`, `packages/app/src/utils/prompt.ts` (plus new keys added in both dictionaries). +- Dictionary parity check: `en.ts` and `zh.ts` currently contain the same key set (371 keys each; no missing or extra keys). ## Methodology @@ -105,36 +105,33 @@ Completed (2026-01-20): File: `packages/app/src/components/session-lsp-indicator.tsx` -**Untranslated strings** -- Tooltip: "No LSP servers" -- Label suffix: "{connected} LSP" (acronym likely fine; the framing text should be localized) +Completed (2026-01-20): + +- Localized tooltip/label framing via `lsp.*` keys (kept the acronym itself). ### 9) Session Tab Close Tooltip File: `packages/app/src/components/session/session-sortable-tab.tsx` -**Untranslated strings** -- Tooltip: "Close tab" +Completed (2026-01-20): -Note: you already have `common.closeTab`. +- Reused `common.closeTab` for the close tooltip. ### 10) Titlebar Tooltip File: `packages/app/src/components/titlebar.tsx` -**Untranslated strings** -- "Toggle sidebar" +Completed (2026-01-20): -Note: can likely reuse `command.sidebar.toggle`. +- Reused `command.sidebar.toggle` for the tooltip title. ### 11) Model Selection "Recent" Group File: `packages/app/src/components/dialog-select-model.tsx` -**Untranslated / fragile string** -- Hardcoded category name comparisons against "Recent". +Completed (2026-01-20): -Recommendation: introduce a key (e.g. `model.group.recent`) and ensure both the grouping label and the comparator use the localized label, or replace the comparator with an internal enum. +- Removed the unused hardcoded "Recent" group comparisons to avoid locale-coupled sorting. ### 12) Select Server Dialog Placeholder (Optional) @@ -150,22 +147,18 @@ This is an example URL; you may choose to keep it as-is even after translating s File: `packages/app/src/context/notification.tsx` -**Untranslated notification titles / fallback copy** -- "Response ready" -- "Session error" -- Fallback description: "An error occurred" +Completed (2026-01-20): -Recommendation: `notification.session.*` namespace (separate from the permission/question notifications already added). +- Localized OS notification titles/fallback copy via `notification.session.*` keys. ### 14) Global Sync (Bootstrap Errors + Toast) File: `packages/app/src/context/global-sync.tsx` -**Untranslated toast title** -- `Failed to load sessions for ${project}` +Completed (2026-01-20): -**Untranslated fatal init error** -- `Could not connect to server. Is there a server running at \`${globalSDK.url}\`?` +- Localized the sessions list failure toast via `toast.session.listFailed.title`. +- Localized the bootstrap connection error via `error.globalSync.connectFailed`. ### 15) File Load Failure Toast (Duplicate) @@ -173,10 +166,9 @@ Files: - `packages/app/src/context/file.tsx` - `packages/app/src/context/local.tsx` -**Untranslated toast title** -- "Failed to load file" +Completed (2026-01-20): -Recommendation: create one shared key (e.g. `toast.file.loadFailed.title`) and reuse it in both contexts. +- Introduced `toast.file.loadFailed.title` and reused it in both contexts. ### 16) Terminal Naming (Tricky) @@ -195,9 +187,9 @@ Recommendation: File: `packages/app/src/utils/prompt.ts` -- Default filename fallback: "attachment" +Completed (2026-01-20): -Recommendation: `common.attachment` or `prompt.attachment.defaultFilename`. +- Added `common.attachment` and plumbed it into `extractPromptFromParts(...)` as `opts.attachmentName`. ### 18) Dev-only Root Mount Error @@ -209,18 +201,9 @@ This is only thrown in DEV and is more of a developer diagnostic. Optional to tr ## Prioritized Implementation Plan -1. Small stragglers: - - `packages/app/src/components/session-lsp-indicator.tsx` - - `packages/app/src/components/session/session-sortable-tab.tsx` - - `packages/app/src/components/titlebar.tsx` - - `packages/app/src/components/dialog-select-model.tsx` - - `packages/app/src/components/dialog-select-server.tsx` (optional URL placeholder) -2. Context modules: - - `packages/app/src/context/notification.tsx` - - `packages/app/src/context/global-sync.tsx` - - `packages/app/src/context/file.tsx` + `packages/app/src/context/local.tsx` - - `packages/app/src/utils/prompt.ts` -3. Decide on the terminal naming approach (`packages/app/src/context/terminal.tsx`). +1. Decide on the terminal naming approach (`packages/app/src/context/terminal.tsx`). +2. Optional: `packages/app/src/components/dialog-select-server.tsx` placeholder example URL. +3. Optional: `packages/app/src/entry.tsx` dev-only root mount error. ## Suggested Key Naming Conventions @@ -243,19 +226,10 @@ Pages: - (none) Components: -- `packages/app/src/components/session-lsp-indicator.tsx` -- `packages/app/src/components/session/session-sortable-tab.tsx` -- `packages/app/src/components/titlebar.tsx` -- `packages/app/src/components/dialog-select-model.tsx` - `packages/app/src/components/dialog-select-server.tsx` (optional URL placeholder) Context: -- `packages/app/src/context/notification.tsx` -- `packages/app/src/context/global-sync.tsx` -- `packages/app/src/context/file.tsx` -- `packages/app/src/context/local.tsx` - `packages/app/src/context/terminal.tsx` (naming) Utils: -- `packages/app/src/utils/prompt.ts` - `packages/app/src/entry.tsx` (dev-only)