diff options
| author | Adam <[email protected]> | 2026-01-20 15:42:10 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-20 17:58:06 -0600 |
| commit | 6037e88ddf3fd08191dfb5e136796e15e8bc163c (patch) | |
| tree | 050907ccc9ffb8250ba906fa2e76155a1bf1d10d | |
| parent | b13c269162e6c858acbce6cc792636cd4cb921a9 (diff) | |
| download | opencode-6037e88ddf3fd08191dfb5e136796e15e8bc163c.tar.gz opencode-6037e88ddf3fd08191dfb5e136796e15e8bc163c.zip | |
wip(app): i18n
| -rw-r--r-- | packages/ui/src/components/image-preview.tsx | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/list.tsx | 26 | ||||
| -rw-r--r-- | packages/ui/src/components/message-nav.tsx | 7 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 140 | ||||
| -rw-r--r-- | packages/ui/src/components/text-field.tsx | 8 | ||||
| -rw-r--r-- | packages/ui/src/i18n/en.ts | 57 | ||||
| -rw-r--r-- | packages/ui/src/i18n/zh.ts | 57 | ||||
| -rw-r--r-- | specs/07-ui-i18n-audit.md | 20 |
8 files changed, 254 insertions, 65 deletions
diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 900abc725..88bf38980 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -1,4 +1,5 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { useI18n } from "../context/i18n" import { IconButton } from "./icon-button" export interface ImagePreviewProps { @@ -7,6 +8,7 @@ export interface ImagePreviewProps { } export function ImagePreview(props: ImagePreviewProps) { + const i18n = useI18n() return ( <div data-component="image-preview"> <div data-slot="image-preview-container"> @@ -15,7 +17,7 @@ export function ImagePreview(props: ImagePreviewProps) { <Kobalte.CloseButton data-slot="image-preview-close" as={IconButton} icon="close" variant="ghost" /> </div> <div data-slot="image-preview-body"> - <img src={props.src} alt={props.alt ?? "Image preview"} data-slot="image-preview-image" /> + <img src={props.src} alt={props.alt ?? i18n.t("ui.imagePreview.alt")} data-slot="image-preview-image" /> </div> </Kobalte.Content> </div> diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index d086c4a2a..d81440c11 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -1,6 +1,7 @@ import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js" import { createStore } from "solid-js/store" +import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" @@ -30,6 +31,7 @@ export interface ListRef { } export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) { + const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined) const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ @@ -174,6 +176,25 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) ) } + const emptyMessage = () => { + if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading") + if (props.emptyMessage) return props.emptyMessage + + const query = filter() + if (!query) return i18n.t("ui.list.empty") + + const suffix = i18n.t("ui.list.emptyWithFilter.suffix") + return ( + <> + <span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span> + <span data-slot="list-filter">"{query}"</span> + <Show when={suffix}> + <span>{suffix}</span> + </Show> + </> + ) + } + return ( <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}> <Show when={!!props.search}> @@ -208,10 +229,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) fallback={ <div data-slot="list-empty-state"> <div data-slot="list-message"> - {grouped.loading ? props.loadingMessage ?? "Loading" : props.emptyMessage ?? "No results"} - <Show when={!props.emptyMessage && !props.loadingMessage && !!filter()}> - {" "}for <span data-slot="list-filter">"{filter()}"</span> - </Show> + {emptyMessage()} </div> </div> } diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 0dd7c42b0..d151633fa 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -2,6 +2,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" import { Tooltip } from "@kobalte/core/tooltip" +import { useI18n } from "../context/i18n" export function MessageNav( props: ComponentProps<"ul"> & { @@ -12,6 +13,7 @@ export function MessageNav( getLabel?: (message: UserMessage) => string | undefined }, ) { + const i18n = useI18n() const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"]) const content = () => ( @@ -48,7 +50,10 @@ export function MessageNav( data-slot="message-nav-title-preview" data-active={message.id === local.current?.id || undefined} > - <Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message"> + <Show + when={local.getLabel?.(message) ?? message.summary?.title} + fallback={i18n.t("ui.messageNav.newMessage")} + > {local.getLabel?.(message) ?? message.summary?.title} </Show> </div> diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4087705d1..8e2a36885 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -32,6 +32,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" import { useDialog } from "../context/dialog" +import { useI18n } from "../context/i18n" import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Button } from "./button" @@ -67,13 +68,14 @@ function getDiagnostics( } function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { + const i18n = useI18n() return ( <Show when={props.diagnostics.length > 0}> <div data-component="diagnostics"> <For each={props.diagnostics}> {(diagnostic) => ( <div data-slot="diagnostic"> - <span data-slot="diagnostic-label">Error</span> + <span data-slot="diagnostic-label">{i18n.t("ui.messagePart.diagnostic.error")}</span> <span data-slot="diagnostic-location"> [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] </span> @@ -179,81 +181,84 @@ export type ToolInfo = { } export function getToolInfo(tool: string, input: any = {}): ToolInfo { + const i18n = useI18n() switch (tool) { case "read": return { icon: "glasses", - title: "Read", + title: i18n.t("ui.tool.read"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "list": return { icon: "bullet-list", - title: "List", + title: i18n.t("ui.tool.list"), subtitle: input.path ? getFilename(input.path) : undefined, } case "glob": return { icon: "magnifying-glass-menu", - title: "Glob", + title: i18n.t("ui.tool.glob"), subtitle: input.pattern, } case "grep": return { icon: "magnifying-glass-menu", - title: "Grep", + title: i18n.t("ui.tool.grep"), subtitle: input.pattern, } case "webfetch": return { icon: "window-cursor", - title: "Webfetch", + title: i18n.t("ui.tool.webfetch"), subtitle: input.url, } case "task": return { icon: "task", - title: `${input.subagent_type || "task"} Agent`, + title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), subtitle: input.description, } case "bash": return { icon: "console", - title: "Shell", + title: i18n.t("ui.tool.shell"), subtitle: input.description, } case "edit": return { icon: "code-lines", - title: "Edit", + title: i18n.t("ui.messagePart.title.edit"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "write": return { icon: "code-lines", - title: "Write", + title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "apply_patch": return { icon: "code-lines", - title: "Patch", - subtitle: input.files?.length ? `${input.files.length} file${input.files.length > 1 ? "s" : ""}` : undefined, + title: i18n.t("ui.tool.patch"), + subtitle: input.files?.length + ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + : undefined, } case "todowrite": return { icon: "checklist", - title: "To-dos", + title: i18n.t("ui.tool.todos"), } case "todoread": return { icon: "checklist", - title: "Read to-dos", + title: i18n.t("ui.tool.todos.read"), } case "question": return { icon: "bubble-5", - title: "Questions", + title: i18n.t("ui.tool.questions"), } default: return { @@ -297,6 +302,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() + const i18n = useI18n() const [copied, setCopied] = createSignal(false) const [expanded, setExpanded] = createSignal(false) const [canExpand, setCanExpand] = createSignal(false) @@ -385,7 +391,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp </div> } > - <img data-slot="user-message-attachment-image" src={file.url} alt={file.filename ?? "attachment"} /> + <img + data-slot="user-message-attachment-image" + src={file.url} + alt={file.filename ?? i18n.t("ui.message.attachment.alt")} + /> </Show> </div> )} @@ -398,7 +408,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp <button data-slot="user-message-expand" type="button" - aria-label={expanded() ? "Collapse message" : "Expand message"} + aria-label={expanded() ? i18n.t("ui.message.collapse") : i18n.t("ui.message.expand")} onClick={(event) => { event.stopPropagation() toggleExpanded() @@ -407,7 +417,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp <Icon name="chevron-down" size="small" /> </button> <div data-slot="user-message-copy-wrapper"> - <Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}> + <Tooltip value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top" gutter={8}> <IconButton icon={copied() ? "check" : "copy"} variant="secondary" @@ -529,6 +539,7 @@ export const ToolRegistry = { PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() + const i18n = useI18n() const part = props.part as ToolPart const permission = createMemo(() => { @@ -639,13 +650,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { <div data-component="permission-prompt"> <div data-slot="permission-actions"> <Button variant="ghost" size="small" onClick={() => respond("reject")}> - Deny + {i18n.t("ui.permission.deny")} </Button> <Button variant="secondary" size="small" onClick={() => respond("always")}> - Allow always + {i18n.t("ui.permission.allowAlways")} </Button> <Button variant="primary" size="small" onClick={() => respond("once")}> - Allow once + {i18n.t("ui.permission.allowOnce")} </Button> </div> </div> @@ -687,6 +698,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { ToolRegistry.register({ name: "read", render(props) { + const i18n = useI18n() const args: string[] = [] if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) @@ -695,7 +707,7 @@ ToolRegistry.register({ {...props} icon="glasses" trigger={{ - title: "Read", + title: i18n.t("ui.tool.read"), subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", args, }} @@ -707,11 +719,12 @@ ToolRegistry.register({ ToolRegistry.register({ name: "list", render(props) { + const i18n = useI18n() return ( <BasicTool {...props} icon="bullet-list" - trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }} + trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} > <Show when={props.output}> {(output) => ( @@ -728,12 +741,13 @@ ToolRegistry.register({ ToolRegistry.register({ name: "glob", render(props) { + const i18n = useI18n() return ( <BasicTool {...props} icon="magnifying-glass-menu" trigger={{ - title: "Glob", + title: i18n.t("ui.tool.glob"), subtitle: getDirectory(props.input.path || "/"), args: props.input.pattern ? ["pattern=" + props.input.pattern] : [], }} @@ -753,6 +767,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "grep", render(props) { + const i18n = useI18n() const args: string[] = [] if (props.input.pattern) args.push("pattern=" + props.input.pattern) if (props.input.include) args.push("include=" + props.input.include) @@ -761,7 +776,7 @@ ToolRegistry.register({ {...props} icon="magnifying-glass-menu" trigger={{ - title: "Grep", + title: i18n.t("ui.tool.grep"), subtitle: getDirectory(props.input.path || "/"), args, }} @@ -781,12 +796,13 @@ ToolRegistry.register({ ToolRegistry.register({ name: "webfetch", render(props) { + const i18n = useI18n() return ( <BasicTool {...props} icon="window-cursor" trigger={{ - title: "Webfetch", + title: i18n.t("ui.tool.webfetch"), subtitle: props.input.url || "", args: props.input.format ? ["format=" + props.input.format] : [], action: ( @@ -812,6 +828,7 @@ ToolRegistry.register({ name: "task", render(props) { const data = useData() + const i18n = useI18n() const summary = () => (props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[] @@ -899,7 +916,7 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: `${props.input.subagent_type || props.tool} Agent`, + title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), titleClass: "capitalize", subtitle: props.input.description, }} @@ -912,13 +929,13 @@ ToolRegistry.register({ <div data-component="permission-prompt"> <div data-slot="permission-actions"> <Button variant="ghost" size="small" onClick={() => respond("reject")}> - Deny + {i18n.t("ui.permission.deny")} </Button> <Button variant="secondary" size="small" onClick={() => respond("always")}> - Allow always + {i18n.t("ui.permission.allowAlways")} </Button> <Button variant="primary" size="small" onClick={() => respond("once")}> - Allow once + {i18n.t("ui.permission.allowOnce")} </Button> </div> </div> @@ -929,7 +946,7 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: `${props.input.subagent_type || props.tool} Agent`, + title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), titleClass: "capitalize", subtitle: props.input.description, }} @@ -969,12 +986,13 @@ ToolRegistry.register({ ToolRegistry.register({ name: "bash", render(props) { + const i18n = useI18n() return ( <BasicTool {...props} icon="console" trigger={{ - title: "Shell", + title: i18n.t("ui.tool.shell"), subtitle: props.input.description, }} > @@ -991,6 +1009,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { + const i18n = useI18n() const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") @@ -1001,7 +1020,9 @@ ToolRegistry.register({ trigger={ <div data-component="edit-trigger"> <div data-slot="message-part-title-area"> - <div data-slot="message-part-title">Edit {filename()}</div> + <div data-slot="message-part-title"> + {i18n.t("ui.messagePart.title.edit")} {filename()} + </div> <Show when={props.input.filePath?.includes("/")}> <div data-slot="message-part-path"> <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> @@ -1040,6 +1061,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + const i18n = useI18n() const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") @@ -1050,7 +1072,9 @@ ToolRegistry.register({ trigger={ <div data-component="write-trigger"> <div data-slot="message-part-title-area"> - <div data-slot="message-part-title">Write {filename()}</div> + <div data-slot="message-part-title"> + {i18n.t("ui.messagePart.title.write")} {filename()} + </div> <Show when={props.input.filePath?.includes("/")}> <div data-slot="message-part-path"> <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> @@ -1095,13 +1119,14 @@ interface ApplyPatchFile { ToolRegistry.register({ name: "apply_patch", render(props) { + const i18n = useI18n() const diffComponent = useDiffComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" - return `${count} file${count > 1 ? "s" : ""}` + return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` }) return ( @@ -1109,7 +1134,7 @@ ToolRegistry.register({ {...props} icon="code-lines" trigger={{ - title: "Patch", + title: i18n.t("ui.tool.patch"), subtitle: subtitle(), }} > @@ -1122,22 +1147,22 @@ ToolRegistry.register({ <Switch> <Match when={file.type === "delete"}> <span data-slot="apply-patch-file-action" data-type="delete"> - Deleted + {i18n.t("ui.patch.action.deleted")} </span> </Match> <Match when={file.type === "add"}> <span data-slot="apply-patch-file-action" data-type="add"> - Created + {i18n.t("ui.patch.action.created")} </span> </Match> <Match when={file.type === "move"}> <span data-slot="apply-patch-file-action" data-type="move"> - Moved + {i18n.t("ui.patch.action.moved")} </span> </Match> <Match when={file.type === "update"}> <span data-slot="apply-patch-file-action" data-type="update"> - Patched + {i18n.t("ui.patch.action.patched")} </span> </Match> </Switch> @@ -1171,6 +1196,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "todowrite", render(props) { + const i18n = useI18n() const todos = createMemo(() => { const meta = props.metadata?.todos if (Array.isArray(meta)) return meta @@ -1193,7 +1219,7 @@ ToolRegistry.register({ defaultOpen icon="checklist" trigger={{ - title: "To-dos", + title: i18n.t("ui.tool.todos"), subtitle: subtitle(), }} > @@ -1218,6 +1244,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "question", render(props) { + const i18n = useI18n() const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) @@ -1225,8 +1252,8 @@ ToolRegistry.register({ const subtitle = createMemo(() => { const count = questions().length if (count === 0) return "" - if (completed()) return `${count} answered` - return `${count} question${count > 1 ? "s" : ""}` + if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) + return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` }) return ( @@ -1235,7 +1262,7 @@ ToolRegistry.register({ defaultOpen={completed()} icon="bubble-5" trigger={{ - title: "Questions", + title: i18n.t("ui.tool.questions"), subtitle: subtitle(), }} > @@ -1247,7 +1274,7 @@ ToolRegistry.register({ return ( <div data-slot="question-answer-item"> <div data-slot="question-text">{q.question}</div> - <div data-slot="answer-text">{answer().join(", ") || "(no answer)"}</div> + <div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div> </div> ) }} @@ -1261,6 +1288,7 @@ ToolRegistry.register({ function QuestionPrompt(props: { request: QuestionRequest }) { const data = useData() + const i18n = useI18n() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -1387,7 +1415,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { }} </For> <button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}> - Confirm + {i18n.t("ui.common.confirm")} </button> </div> </Show> @@ -1396,7 +1424,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { <div data-slot="question-content"> <div data-slot="question-text"> {question()?.question} - {multi() ? " (select all that apply)" : ""} + {multi() ? " " + i18n.t("ui.question.multiHint") : ""} </div> <div data-slot="question-options"> <For each={options()}> @@ -1420,7 +1448,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { data-picked={customPicked()} onClick={() => selectOption(options().length)} > - <span data-slot="option-label">Type your own answer</span> + <span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span> <Show when={!store.editing && input()}> <span data-slot="option-description">{input()}</span> </Show> @@ -1434,7 +1462,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { ref={(el) => setTimeout(() => el.focus(), 0)} type="text" data-slot="custom-input" - placeholder="Type your answer..." + placeholder={i18n.t("ui.question.custom.placeholder")} value={input()} onInput={(e) => { const inputs = [...store.custom] @@ -1443,10 +1471,10 @@ function QuestionPrompt(props: { request: QuestionRequest }) { }} /> <Button type="submit" variant="primary" size="small"> - {multi() ? "Add" : "Submit"} + {multi() ? i18n.t("ui.common.add") : i18n.t("ui.common.submit")} </Button> <Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}> - Cancel + {i18n.t("ui.common.cancel")} </Button> </form> </Show> @@ -1456,7 +1484,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { <Show when={confirm()}> <div data-slot="question-review"> - <div data-slot="review-title">Review your answers</div> + <div data-slot="review-title">{i18n.t("ui.messagePart.review.title")}</div> <For each={questions()}> {(q, index) => { const value = () => store.answers[index()]?.join(", ") ?? "" @@ -1465,7 +1493,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { <div data-slot="review-item"> <span data-slot="review-label">{q.question}</span> <span data-slot="review-value" data-answered={answered()}> - {answered() ? value() : "(not answered)"} + {answered() ? value() : i18n.t("ui.question.review.notAnswered")} </span> </div> ) @@ -1476,12 +1504,12 @@ function QuestionPrompt(props: { request: QuestionRequest }) { <div data-slot="question-actions"> <Button variant="ghost" size="small" onClick={reject}> - Dismiss + {i18n.t("ui.common.dismiss")} </Button> <Show when={!single()}> <Show when={confirm()}> <Button variant="primary" size="small" onClick={submit}> - Submit + {i18n.t("ui.common.submit")} </Button> </Show> <Show when={!confirm() && multi()}> @@ -1491,7 +1519,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { onClick={() => selectTab(store.tab + 1)} disabled={(store.answers[store.tab]?.length ?? 0) === 0} > - Next + {i18n.t("ui.common.next")} </Button> </Show> </Show> diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index ed3d13fe3..a4eafa561 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -1,6 +1,7 @@ import { TextField as Kobalte } from "@kobalte/core/text-field" import { createSignal, Show, splitProps } from "solid-js" import type { ComponentProps } from "solid-js" +import { useI18n } from "../context/i18n" import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" @@ -30,6 +31,7 @@ export interface TextFieldProps } export function TextField(props: TextFieldProps) { + const i18n = useI18n() const [local, others] = splitProps(props, [ "name", "defaultValue", @@ -90,7 +92,11 @@ export function TextField(props: TextFieldProps) { <Kobalte.TextArea {...others} autoResize data-slot="input-input" class={local.class} /> </Show> <Show when={local.copyable}> - <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}> + <Tooltip + value={copied() ? i18n.t("ui.textField.copied") : i18n.t("ui.textField.copyToClipboard")} + placement="top" + gutter={8} + > <IconButton type="button" icon={copied() ? "check" : "copy"} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index caee7e536..49b67900e 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -30,4 +30,61 @@ export const dict = { "ui.messagePart.title.write": "Write", "ui.messagePart.option.typeOwnAnswer": "Type your own answer", "ui.messagePart.review.title": "Review your answers", + + "ui.list.loading": "Loading", + "ui.list.empty": "No results", + "ui.list.emptyWithFilter.prefix": "No results for", + "ui.list.emptyWithFilter.suffix": "", + + "ui.messageNav.newMessage": "New message", + + "ui.textField.copyToClipboard": "Copy to clipboard", + "ui.textField.copied": "Copied", + + "ui.imagePreview.alt": "Image preview", + + "ui.tool.read": "Read", + "ui.tool.list": "List", + "ui.tool.glob": "Glob", + "ui.tool.grep": "Grep", + "ui.tool.webfetch": "Webfetch", + "ui.tool.shell": "Shell", + "ui.tool.patch": "Patch", + "ui.tool.todos": "To-dos", + "ui.tool.todos.read": "Read to-dos", + "ui.tool.questions": "Questions", + "ui.tool.agent": "{{type}} Agent", + + "ui.common.file.one": "file", + "ui.common.file.other": "files", + "ui.common.question.one": "question", + "ui.common.question.other": "questions", + + "ui.common.add": "Add", + "ui.common.cancel": "Cancel", + "ui.common.confirm": "Confirm", + "ui.common.dismiss": "Dismiss", + "ui.common.next": "Next", + "ui.common.submit": "Submit", + + "ui.permission.deny": "Deny", + "ui.permission.allowAlways": "Allow always", + "ui.permission.allowOnce": "Allow once", + + "ui.message.expand": "Expand message", + "ui.message.collapse": "Collapse message", + "ui.message.copy": "Copy", + "ui.message.copied": "Copied!", + "ui.message.attachment.alt": "attachment", + + "ui.patch.action.deleted": "Deleted", + "ui.patch.action.created": "Created", + "ui.patch.action.moved": "Moved", + "ui.patch.action.patched": "Patched", + + "ui.question.subtitle.answered": "{{count}} answered", + "ui.question.answer.none": "(no answer)", + "ui.question.review.notAnswered": "(not answered)", + "ui.question.multiHint": "(select all that apply)", + "ui.question.custom.placeholder": "Type your answer...", } diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 59cbe6e27..327c4e457 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -34,4 +34,61 @@ export const dict = { "ui.messagePart.title.write": "写入", "ui.messagePart.option.typeOwnAnswer": "输入自己的答案", "ui.messagePart.review.title": "检查你的答案", + + "ui.list.loading": "加载中", + "ui.list.empty": "无结果", + "ui.list.emptyWithFilter.prefix": "没有关于", + "ui.list.emptyWithFilter.suffix": "的结果", + + "ui.messageNav.newMessage": "新消息", + + "ui.textField.copyToClipboard": "复制到剪贴板", + "ui.textField.copied": "已复制", + + "ui.imagePreview.alt": "图片预览", + + "ui.tool.read": "读取", + "ui.tool.list": "列表", + "ui.tool.glob": "Glob", + "ui.tool.grep": "Grep", + "ui.tool.webfetch": "Webfetch", + "ui.tool.shell": "Shell", + "ui.tool.patch": "补丁", + "ui.tool.todos": "待办", + "ui.tool.todos.read": "读取待办", + "ui.tool.questions": "问题", + "ui.tool.agent": "{{type}} 智能体", + + "ui.common.file.one": "个文件", + "ui.common.file.other": "个文件", + "ui.common.question.one": "个问题", + "ui.common.question.other": "个问题", + + "ui.common.add": "添加", + "ui.common.cancel": "取消", + "ui.common.confirm": "确认", + "ui.common.dismiss": "忽略", + "ui.common.next": "下一步", + "ui.common.submit": "提交", + + "ui.permission.deny": "拒绝", + "ui.permission.allowAlways": "始终允许", + "ui.permission.allowOnce": "允许一次", + + "ui.message.expand": "展开消息", + "ui.message.collapse": "收起消息", + "ui.message.copy": "复制", + "ui.message.copied": "已复制", + "ui.message.attachment.alt": "附件", + + "ui.patch.action.deleted": "已删除", + "ui.patch.action.created": "已创建", + "ui.patch.action.moved": "已移动", + "ui.patch.action.patched": "已应用补丁", + + "ui.question.subtitle.answered": "{{count}} 已回答", + "ui.question.answer.none": "(无答案)", + "ui.question.review.notAnswered": "(未回答)", + "ui.question.multiHint": "(可多选)", + "ui.question.custom.placeholder": "输入你的答案...", } satisfies Partial<Record<Keys, string>> diff --git a/specs/07-ui-i18n-audit.md b/specs/07-ui-i18n-audit.md index f6f67db73..26bc552bb 100644 --- a/specs/07-ui-i18n-audit.md +++ b/specs/07-ui-i18n-audit.md @@ -120,6 +120,22 @@ Examples (non-exhaustive): - `Type your own answer` - `Review your answers` +### 4) Additional Hardcoded Strings (Full Audit) + +Found during a full `packages/ui/src/components` + `packages/ui/src/context` sweep: + +- `packages/ui/src/components/list.tsx` + - `Loading` + - `No results` + - `No results for "{{filter}}"` +- `packages/ui/src/components/message-nav.tsx` + - `New message` +- `packages/ui/src/components/text-field.tsx` + - `Copied` + - `Copy to clipboard` +- `packages/ui/src/components/image-preview.tsx` + - `Image preview` (alt text) + ## Prioritized Implementation Plan 1. Completed (2026-01-20): Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. @@ -128,8 +144,8 @@ Examples (non-exhaustive): - `packages/app/src/app.tsx` - `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. +5. Completed (2026-01-20): Convert `packages/ui/src/components/message-part.tsx`. +6. Completed (2026-01-20): Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy. ## Notes / Risks |
